spindb 0.1.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.
Files changed (41) hide show
  1. package/.claude/settings.local.json +20 -0
  2. package/.env.example +1 -0
  3. package/.prettierignore +4 -0
  4. package/.prettierrc +6 -0
  5. package/CLAUDE.md +162 -0
  6. package/README.md +204 -0
  7. package/TODO.md +66 -0
  8. package/bin/cli.js +7 -0
  9. package/eslint.config.js +18 -0
  10. package/package.json +52 -0
  11. package/seeds/mysql/sample-db.sql +22 -0
  12. package/seeds/postgres/sample-db.sql +27 -0
  13. package/src/bin/cli.ts +8 -0
  14. package/src/cli/commands/clone.ts +101 -0
  15. package/src/cli/commands/config.ts +215 -0
  16. package/src/cli/commands/connect.ts +106 -0
  17. package/src/cli/commands/create.ts +148 -0
  18. package/src/cli/commands/delete.ts +94 -0
  19. package/src/cli/commands/list.ts +69 -0
  20. package/src/cli/commands/menu.ts +675 -0
  21. package/src/cli/commands/restore.ts +161 -0
  22. package/src/cli/commands/start.ts +95 -0
  23. package/src/cli/commands/stop.ts +91 -0
  24. package/src/cli/index.ts +38 -0
  25. package/src/cli/ui/prompts.ts +197 -0
  26. package/src/cli/ui/spinner.ts +94 -0
  27. package/src/cli/ui/theme.ts +113 -0
  28. package/src/config/defaults.ts +49 -0
  29. package/src/config/paths.ts +53 -0
  30. package/src/core/binary-manager.ts +239 -0
  31. package/src/core/config-manager.ts +259 -0
  32. package/src/core/container-manager.ts +234 -0
  33. package/src/core/port-manager.ts +84 -0
  34. package/src/core/process-manager.ts +353 -0
  35. package/src/engines/base-engine.ts +103 -0
  36. package/src/engines/index.ts +46 -0
  37. package/src/engines/postgresql/binary-urls.ts +52 -0
  38. package/src/engines/postgresql/index.ts +298 -0
  39. package/src/engines/postgresql/restore.ts +173 -0
  40. package/src/types/index.ts +97 -0
  41. package/tsconfig.json +24 -0
@@ -0,0 +1,161 @@
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 { promptContainerSelect, promptDatabaseName } from '@/cli/ui/prompts'
8
+ import { createSpinner } from '@/cli/ui/spinner'
9
+ import { success, error, warning } from '@/cli/ui/theme'
10
+
11
+ export const restoreCommand = new Command('restore')
12
+ .description('Restore a backup to a container')
13
+ .argument('[name]', 'Container name')
14
+ .argument('[backup]', 'Path to backup file')
15
+ .option('-d, --database <name>', 'Target database name')
16
+ .action(
17
+ async (
18
+ name: string | undefined,
19
+ backup: string | undefined,
20
+ options: { database?: string },
21
+ ) => {
22
+ try {
23
+ let containerName = name
24
+ const backupPath = backup
25
+
26
+ // Interactive selection if no name provided
27
+ if (!containerName) {
28
+ const containers = await containerManager.list()
29
+ const running = containers.filter((c) => c.status === 'running')
30
+
31
+ if (running.length === 0) {
32
+ if (containers.length === 0) {
33
+ console.log(
34
+ warning('No containers found. Create one with: spindb create'),
35
+ )
36
+ } else {
37
+ console.log(
38
+ warning(
39
+ 'No running containers. Start one first with: spindb start',
40
+ ),
41
+ )
42
+ }
43
+ return
44
+ }
45
+
46
+ const selected = await promptContainerSelect(
47
+ running,
48
+ 'Select container to restore to:',
49
+ )
50
+ if (!selected) return
51
+ containerName = selected
52
+ }
53
+
54
+ // Get container config
55
+ const config = await containerManager.getConfig(containerName)
56
+ if (!config) {
57
+ console.error(error(`Container "${containerName}" not found`))
58
+ process.exit(1)
59
+ }
60
+
61
+ // Check if running
62
+ const running = await processManager.isRunning(containerName)
63
+ if (!running) {
64
+ console.error(
65
+ error(
66
+ `Container "${containerName}" is not running. Start it first.`,
67
+ ),
68
+ )
69
+ process.exit(1)
70
+ }
71
+
72
+ // Check backup file
73
+ if (!backupPath) {
74
+ console.error(error('Backup file path is required'))
75
+ console.log(
76
+ chalk.gray(' Usage: spindb restore <container> <backup-file>'),
77
+ )
78
+ process.exit(1)
79
+ }
80
+
81
+ if (!existsSync(backupPath)) {
82
+ console.error(error(`Backup file not found: ${backupPath}`))
83
+ process.exit(1)
84
+ }
85
+
86
+ // Get database name
87
+ let databaseName = options.database
88
+ if (!databaseName) {
89
+ databaseName = await promptDatabaseName(containerName)
90
+ }
91
+
92
+ // Get engine
93
+ const engine = getEngine(config.engine)
94
+
95
+ // Detect backup format
96
+ const detectSpinner = createSpinner('Detecting backup format...')
97
+ detectSpinner.start()
98
+
99
+ const format = await engine.detectBackupFormat(backupPath)
100
+ detectSpinner.succeed(`Detected: ${format.description}`)
101
+
102
+ // Create database
103
+ const dbSpinner = createSpinner(
104
+ `Creating database "${databaseName}"...`,
105
+ )
106
+ dbSpinner.start()
107
+
108
+ await engine.createDatabase(config, databaseName)
109
+ dbSpinner.succeed(`Database "${databaseName}" ready`)
110
+
111
+ // Restore backup
112
+ const restoreSpinner = createSpinner('Restoring backup...')
113
+ restoreSpinner.start()
114
+
115
+ const result = await engine.restore(config, backupPath, {
116
+ database: databaseName,
117
+ createDatabase: false, // Already created
118
+ })
119
+
120
+ if (result.code === 0 || !result.stderr) {
121
+ restoreSpinner.succeed('Backup restored successfully')
122
+ } else {
123
+ // pg_restore often returns warnings even on success
124
+ restoreSpinner.warn('Restore completed with warnings')
125
+ if (result.stderr) {
126
+ console.log(chalk.yellow('\n Warnings:'))
127
+ const lines = result.stderr.split('\n').slice(0, 5)
128
+ lines.forEach((line) => {
129
+ if (line.trim()) {
130
+ console.log(chalk.gray(` ${line}`))
131
+ }
132
+ })
133
+ if (result.stderr.split('\n').length > 5) {
134
+ console.log(chalk.gray(' ...'))
135
+ }
136
+ }
137
+ }
138
+
139
+ // Show connection info
140
+ const connectionString = engine.getConnectionString(
141
+ config,
142
+ databaseName,
143
+ )
144
+ console.log()
145
+ console.log(success(`Database "${databaseName}" restored`))
146
+ console.log()
147
+ console.log(chalk.gray(' Connection string:'))
148
+ console.log(chalk.cyan(` ${connectionString}`))
149
+ console.log()
150
+ console.log(chalk.gray(' Connect with:'))
151
+ console.log(
152
+ chalk.cyan(` spindb connect ${containerName} -d ${databaseName}`),
153
+ )
154
+ console.log()
155
+ } catch (err) {
156
+ const e = err as Error
157
+ console.error(error(e.message))
158
+ process.exit(1)
159
+ }
160
+ },
161
+ )
@@ -0,0 +1,95 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { containerManager } from '@/core/container-manager'
4
+ import { portManager } from '@/core/port-manager'
5
+ import { processManager } from '@/core/process-manager'
6
+ import { getEngine } from '@/engines'
7
+ import { promptContainerSelect } from '@/cli/ui/prompts'
8
+ import { createSpinner } from '@/cli/ui/spinner'
9
+ import { error, warning } from '@/cli/ui/theme'
10
+
11
+ export const startCommand = new Command('start')
12
+ .description('Start a container')
13
+ .argument('[name]', 'Container name')
14
+ .action(async (name: string | undefined) => {
15
+ try {
16
+ let containerName = name
17
+
18
+ // Interactive selection if no name provided
19
+ if (!containerName) {
20
+ const containers = await containerManager.list()
21
+ const stopped = containers.filter((c) => c.status !== 'running')
22
+
23
+ if (stopped.length === 0) {
24
+ if (containers.length === 0) {
25
+ console.log(
26
+ warning('No containers found. Create one with: spindb create'),
27
+ )
28
+ } else {
29
+ console.log(warning('All containers are already running'))
30
+ }
31
+ return
32
+ }
33
+
34
+ const selected = await promptContainerSelect(
35
+ stopped,
36
+ 'Select container to start:',
37
+ )
38
+ if (!selected) return
39
+ containerName = selected
40
+ }
41
+
42
+ // Get container config
43
+ const config = await containerManager.getConfig(containerName)
44
+ if (!config) {
45
+ console.error(error(`Container "${containerName}" not found`))
46
+ process.exit(1)
47
+ }
48
+
49
+ // Check if already running
50
+ const running = await processManager.isRunning(containerName)
51
+ if (running) {
52
+ console.log(warning(`Container "${containerName}" is already running`))
53
+ return
54
+ }
55
+
56
+ // Check port availability
57
+ const portAvailable = await portManager.isPortAvailable(config.port)
58
+ if (!portAvailable) {
59
+ // Try to find a new port
60
+ const { port: newPort } = await portManager.findAvailablePort()
61
+ console.log(
62
+ warning(
63
+ `Port ${config.port} is in use, switching to port ${newPort}`,
64
+ ),
65
+ )
66
+ config.port = newPort
67
+ await containerManager.updateConfig(containerName, { port: newPort })
68
+ }
69
+
70
+ // Get engine and start
71
+ const engine = getEngine(config.engine)
72
+
73
+ const spinner = createSpinner(`Starting ${containerName}...`)
74
+ spinner.start()
75
+
76
+ await engine.start(config)
77
+ await containerManager.updateConfig(containerName, { status: 'running' })
78
+
79
+ spinner.succeed(`Container "${containerName}" started`)
80
+
81
+ // Show connection info
82
+ const connectionString = engine.getConnectionString(config)
83
+ console.log()
84
+ console.log(chalk.gray(' Connection string:'))
85
+ console.log(chalk.cyan(` ${connectionString}`))
86
+ console.log()
87
+ console.log(chalk.gray(' Connect with:'))
88
+ console.log(chalk.cyan(` spindb connect ${containerName}`))
89
+ console.log()
90
+ } catch (err) {
91
+ const e = err as Error
92
+ console.error(error(e.message))
93
+ process.exit(1)
94
+ }
95
+ })
@@ -0,0 +1,91 @@
1
+ import { Command } from 'commander'
2
+ import { containerManager } from '@/core/container-manager'
3
+ import { processManager } from '@/core/process-manager'
4
+ import { getEngine } from '@/engines'
5
+ import { promptContainerSelect } from '@/cli/ui/prompts'
6
+ import { createSpinner } from '@/cli/ui/spinner'
7
+ import { success, error, warning } from '@/cli/ui/theme'
8
+
9
+ export const stopCommand = new Command('stop')
10
+ .description('Stop a container')
11
+ .argument('[name]', 'Container name')
12
+ .option('-a, --all', 'Stop all running containers')
13
+ .action(async (name: string | undefined, options: { all?: boolean }) => {
14
+ try {
15
+ if (options.all) {
16
+ // Stop all running containers
17
+ const containers = await containerManager.list()
18
+ const running = containers.filter((c) => c.status === 'running')
19
+
20
+ if (running.length === 0) {
21
+ console.log(warning('No running containers found'))
22
+ return
23
+ }
24
+
25
+ for (const container of running) {
26
+ const spinner = createSpinner(`Stopping ${container.name}...`)
27
+ spinner.start()
28
+
29
+ const engine = getEngine(container.engine)
30
+ await engine.stop(container)
31
+ await containerManager.updateConfig(container.name, {
32
+ status: 'stopped',
33
+ })
34
+
35
+ spinner.succeed(`Stopped "${container.name}"`)
36
+ }
37
+
38
+ console.log(success(`Stopped ${running.length} container(s)`))
39
+ return
40
+ }
41
+
42
+ let containerName = name
43
+
44
+ // Interactive selection if no name provided
45
+ if (!containerName) {
46
+ const containers = await containerManager.list()
47
+ const running = containers.filter((c) => c.status === 'running')
48
+
49
+ if (running.length === 0) {
50
+ console.log(warning('No running containers found'))
51
+ return
52
+ }
53
+
54
+ const selected = await promptContainerSelect(
55
+ running,
56
+ 'Select container to stop:',
57
+ )
58
+ if (!selected) return
59
+ containerName = selected
60
+ }
61
+
62
+ // Get container config
63
+ const config = await containerManager.getConfig(containerName)
64
+ if (!config) {
65
+ console.error(error(`Container "${containerName}" not found`))
66
+ process.exit(1)
67
+ }
68
+
69
+ // Check if running
70
+ const running = await processManager.isRunning(containerName)
71
+ if (!running) {
72
+ console.log(warning(`Container "${containerName}" is not running`))
73
+ return
74
+ }
75
+
76
+ // Get engine and stop
77
+ const engine = getEngine(config.engine)
78
+
79
+ const spinner = createSpinner(`Stopping ${containerName}...`)
80
+ spinner.start()
81
+
82
+ await engine.stop(config)
83
+ await containerManager.updateConfig(containerName, { status: 'stopped' })
84
+
85
+ spinner.succeed(`Container "${containerName}" stopped`)
86
+ } catch (err) {
87
+ const e = err as Error
88
+ console.error(error(e.message))
89
+ process.exit(1)
90
+ }
91
+ })
@@ -0,0 +1,38 @@
1
+ import { program } from 'commander'
2
+ import { createCommand } from '@/cli/commands/create'
3
+ import { listCommand } from '@/cli/commands/list'
4
+ import { startCommand } from '@/cli/commands/start'
5
+ import { stopCommand } from '@/cli/commands/stop'
6
+ import { deleteCommand } from '@/cli/commands/delete'
7
+ import { restoreCommand } from '@/cli/commands/restore'
8
+ import { connectCommand } from '@/cli/commands/connect'
9
+ import { cloneCommand } from '@/cli/commands/clone'
10
+ import { menuCommand } from '@/cli/commands/menu'
11
+ import { configCommand } from '@/cli/commands/config'
12
+
13
+ export async function run(): Promise<void> {
14
+ program
15
+ .name('spindb')
16
+ .description('Spin up local database containers without Docker')
17
+ .version('0.1.0')
18
+
19
+ program.addCommand(createCommand)
20
+ program.addCommand(listCommand)
21
+ program.addCommand(startCommand)
22
+ program.addCommand(stopCommand)
23
+ program.addCommand(deleteCommand)
24
+ program.addCommand(restoreCommand)
25
+ program.addCommand(connectCommand)
26
+ program.addCommand(cloneCommand)
27
+ program.addCommand(menuCommand)
28
+ program.addCommand(configCommand)
29
+
30
+ // If no arguments provided, show interactive menu
31
+ if (process.argv.length <= 2) {
32
+ const { menuCommand: menu } = await import('@/cli/commands/menu')
33
+ await menu.parseAsync([])
34
+ return
35
+ }
36
+
37
+ program.parse()
38
+ }
@@ -0,0 +1,197 @@
1
+ import inquirer from 'inquirer'
2
+ import chalk from 'chalk'
3
+ import { listEngines } from '@/engines'
4
+ import { defaults } from '@/config/defaults'
5
+ import type { ContainerConfig } from '@/types'
6
+
7
+ /**
8
+ * Prompt for container name
9
+ */
10
+ export async function promptContainerName(
11
+ defaultName?: string,
12
+ ): Promise<string> {
13
+ const { name } = await inquirer.prompt<{ name: string }>([
14
+ {
15
+ type: 'input',
16
+ name: 'name',
17
+ message: 'Container name:',
18
+ default: defaultName,
19
+ validate: (input: string) => {
20
+ if (!input) return 'Name is required'
21
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(input)) {
22
+ return 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
23
+ }
24
+ return true
25
+ },
26
+ },
27
+ ])
28
+ return name
29
+ }
30
+
31
+ /**
32
+ * Prompt for database engine selection
33
+ */
34
+ export async function promptEngine(): Promise<string> {
35
+ const engines = listEngines()
36
+
37
+ const { engine } = await inquirer.prompt<{ engine: string }>([
38
+ {
39
+ type: 'list',
40
+ name: 'engine',
41
+ message: 'Select database engine:',
42
+ choices: engines.map((e) => ({
43
+ name: `${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
44
+ value: e.name,
45
+ short: e.displayName,
46
+ })),
47
+ },
48
+ ])
49
+
50
+ return engine
51
+ }
52
+
53
+ /**
54
+ * Prompt for PostgreSQL version
55
+ */
56
+ export async function promptVersion(engine: string): Promise<string> {
57
+ const engines = listEngines()
58
+ const selectedEngine = engines.find((e) => e.name === engine)
59
+ const versions =
60
+ selectedEngine?.supportedVersions || defaults.supportedPostgresVersions
61
+
62
+ const { version } = await inquirer.prompt<{ version: string }>([
63
+ {
64
+ type: 'list',
65
+ name: 'version',
66
+ message: 'Select version:',
67
+ choices: versions.map((v, i) => ({
68
+ name: i === versions.length - 1 ? `${v} ${chalk.green('(latest)')}` : v,
69
+ value: v,
70
+ })),
71
+ default: versions[versions.length - 1], // Default to latest
72
+ },
73
+ ])
74
+
75
+ return version
76
+ }
77
+
78
+ /**
79
+ * Prompt for port
80
+ */
81
+ export async function promptPort(
82
+ defaultPort: number = defaults.port,
83
+ ): Promise<number> {
84
+ const { port } = await inquirer.prompt<{ port: number }>([
85
+ {
86
+ type: 'input',
87
+ name: 'port',
88
+ message: 'Port:',
89
+ default: String(defaultPort),
90
+ validate: (input: string) => {
91
+ const num = parseInt(input, 10)
92
+ if (isNaN(num) || num < 1 || num > 65535) {
93
+ return 'Port must be a number between 1 and 65535'
94
+ }
95
+ return true
96
+ },
97
+ filter: (input: string) => parseInt(input, 10),
98
+ },
99
+ ])
100
+
101
+ return port
102
+ }
103
+
104
+ /**
105
+ * Prompt for confirmation using arrow-key selection
106
+ */
107
+ export async function promptConfirm(
108
+ message: string,
109
+ defaultValue: boolean = true,
110
+ ): Promise<boolean> {
111
+ const { confirmed } = await inquirer.prompt<{ confirmed: string }>([
112
+ {
113
+ type: 'list',
114
+ name: 'confirmed',
115
+ message,
116
+ choices: [
117
+ { name: 'Yes', value: 'yes' },
118
+ { name: 'No', value: 'no' },
119
+ ],
120
+ default: defaultValue ? 'yes' : 'no',
121
+ },
122
+ ])
123
+
124
+ return confirmed === 'yes'
125
+ }
126
+
127
+ /**
128
+ * Prompt for container selection from a list
129
+ */
130
+ export async function promptContainerSelect(
131
+ containers: ContainerConfig[],
132
+ message: string = 'Select container:',
133
+ ): Promise<string | null> {
134
+ if (containers.length === 0) {
135
+ return null
136
+ }
137
+
138
+ const { container } = await inquirer.prompt<{ container: string }>([
139
+ {
140
+ type: 'list',
141
+ name: 'container',
142
+ message,
143
+ choices: containers.map((c) => ({
144
+ name: `${c.name} ${chalk.gray(`(${c.engine} ${c.version}, port ${c.port})`)} ${
145
+ c.status === 'running'
146
+ ? chalk.green('● running')
147
+ : chalk.gray('○ stopped')
148
+ }`,
149
+ value: c.name,
150
+ short: c.name,
151
+ })),
152
+ },
153
+ ])
154
+
155
+ return container
156
+ }
157
+
158
+ /**
159
+ * Prompt for database name
160
+ */
161
+ export async function promptDatabaseName(
162
+ defaultName?: string,
163
+ ): Promise<string> {
164
+ const { database } = await inquirer.prompt<{ database: string }>([
165
+ {
166
+ type: 'input',
167
+ name: 'database',
168
+ message: 'Database name:',
169
+ default: defaultName,
170
+ validate: (input: string) => {
171
+ if (!input) return 'Database name is required'
172
+ return true
173
+ },
174
+ },
175
+ ])
176
+
177
+ return database
178
+ }
179
+
180
+ export interface CreateOptions {
181
+ name: string
182
+ engine: string
183
+ version: string
184
+ }
185
+
186
+ /**
187
+ * Full interactive create flow
188
+ */
189
+ export async function promptCreateOptions(): Promise<CreateOptions> {
190
+ console.log(chalk.cyan('\n 🗄️ Create New Database Container\n'))
191
+
192
+ const name = await promptContainerName()
193
+ const engine = await promptEngine()
194
+ const version = await promptVersion(engine)
195
+
196
+ return { name, engine, version }
197
+ }
@@ -0,0 +1,94 @@
1
+ import ora, { type Ora } from 'ora'
2
+
3
+ /**
4
+ * Create a spinner with consistent styling
5
+ */
6
+ export function createSpinner(text: string): Ora {
7
+ return ora({
8
+ text,
9
+ color: 'cyan',
10
+ spinner: 'dots',
11
+ })
12
+ }
13
+
14
+ /**
15
+ * Run an async operation with a spinner
16
+ */
17
+ export async function withSpinner<T>(
18
+ text: string,
19
+ operation: (updateText: (message: string) => void) => Promise<T>,
20
+ ): Promise<T> {
21
+ const spinner = createSpinner(text)
22
+ spinner.start()
23
+
24
+ try {
25
+ const result = await operation((message: string) => {
26
+ spinner.text = message
27
+ })
28
+ spinner.succeed()
29
+ return result
30
+ } catch (err) {
31
+ const error = err as Error
32
+ spinner.fail(error.message)
33
+ throw error
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Progress tracker for multi-step operations
39
+ */
40
+ export class ProgressTracker {
41
+ private steps: string[]
42
+ private currentStep: number
43
+ private spinner: Ora | null
44
+
45
+ constructor(steps: string[]) {
46
+ this.steps = steps
47
+ this.currentStep = 0
48
+ this.spinner = null
49
+ }
50
+
51
+ start(): void {
52
+ if (this.steps.length > 0) {
53
+ this.spinner = createSpinner(this.steps[0])
54
+ this.spinner.start()
55
+ }
56
+ }
57
+
58
+ nextStep(): void {
59
+ if (this.spinner) {
60
+ this.spinner.succeed()
61
+ }
62
+
63
+ this.currentStep++
64
+
65
+ if (this.currentStep < this.steps.length) {
66
+ this.spinner = createSpinner(this.steps[this.currentStep])
67
+ this.spinner.start()
68
+ }
69
+ }
70
+
71
+ updateText(text: string): void {
72
+ if (this.spinner) {
73
+ this.spinner.text = text
74
+ }
75
+ }
76
+
77
+ succeed(text?: string): void {
78
+ if (this.spinner) {
79
+ this.spinner.succeed(text)
80
+ }
81
+ }
82
+
83
+ fail(text?: string): void {
84
+ if (this.spinner) {
85
+ this.spinner.fail(text)
86
+ }
87
+ }
88
+
89
+ warn(text?: string): void {
90
+ if (this.spinner) {
91
+ this.spinner.warn(text)
92
+ }
93
+ }
94
+ }