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,234 @@
1
+ import { existsSync } from 'fs'
2
+ import { mkdir, readdir, readFile, writeFile, rm, cp } from 'fs/promises'
3
+ import { paths } from '@/config/paths'
4
+ import { processManager } from '@/core/process-manager'
5
+ import { portManager } from '@/core/port-manager'
6
+ import type { ContainerConfig } from '@/types'
7
+
8
+ export interface CreateOptions {
9
+ engine: string
10
+ version: string
11
+ port: number
12
+ }
13
+
14
+ export interface DeleteOptions {
15
+ force?: boolean
16
+ }
17
+
18
+ export class ContainerManager {
19
+ /**
20
+ * Create a new container
21
+ */
22
+ async create(name: string, options: CreateOptions): Promise<ContainerConfig> {
23
+ const { engine, version, port } = options
24
+
25
+ // Validate container name
26
+ if (!this.isValidName(name)) {
27
+ throw new Error(
28
+ 'Container name must be alphanumeric with hyphens/underscores only',
29
+ )
30
+ }
31
+
32
+ // Check if container already exists
33
+ if (await this.exists(name)) {
34
+ throw new Error(`Container "${name}" already exists`)
35
+ }
36
+
37
+ // Create container directory
38
+ const containerPath = paths.getContainerPath(name)
39
+ const dataPath = paths.getContainerDataPath(name)
40
+
41
+ await mkdir(containerPath, { recursive: true })
42
+ await mkdir(dataPath, { recursive: true })
43
+
44
+ // Create container config
45
+ const config: ContainerConfig = {
46
+ name,
47
+ engine,
48
+ version,
49
+ port,
50
+ created: new Date().toISOString(),
51
+ status: 'created',
52
+ }
53
+
54
+ await this.saveConfig(name, config)
55
+
56
+ return config
57
+ }
58
+
59
+ /**
60
+ * Get container configuration
61
+ */
62
+ async getConfig(name: string): Promise<ContainerConfig | null> {
63
+ const configPath = paths.getContainerConfigPath(name)
64
+
65
+ if (!existsSync(configPath)) {
66
+ return null
67
+ }
68
+
69
+ const content = await readFile(configPath, 'utf8')
70
+ return JSON.parse(content) as ContainerConfig
71
+ }
72
+
73
+ /**
74
+ * Save container configuration
75
+ */
76
+ async saveConfig(name: string, config: ContainerConfig): Promise<void> {
77
+ const configPath = paths.getContainerConfigPath(name)
78
+ await writeFile(configPath, JSON.stringify(config, null, 2))
79
+ }
80
+
81
+ /**
82
+ * Update container configuration
83
+ */
84
+ async updateConfig(
85
+ name: string,
86
+ updates: Partial<ContainerConfig>,
87
+ ): Promise<ContainerConfig> {
88
+ const config = await this.getConfig(name)
89
+ if (!config) {
90
+ throw new Error(`Container "${name}" not found`)
91
+ }
92
+
93
+ const updatedConfig = { ...config, ...updates }
94
+ await this.saveConfig(name, updatedConfig)
95
+ return updatedConfig
96
+ }
97
+
98
+ /**
99
+ * Check if a container exists
100
+ */
101
+ async exists(name: string): Promise<boolean> {
102
+ const configPath = paths.getContainerConfigPath(name)
103
+ return existsSync(configPath)
104
+ }
105
+
106
+ /**
107
+ * List all containers
108
+ */
109
+ async list(): Promise<ContainerConfig[]> {
110
+ const containersDir = paths.containers
111
+
112
+ if (!existsSync(containersDir)) {
113
+ return []
114
+ }
115
+
116
+ const entries = await readdir(containersDir, { withFileTypes: true })
117
+ const containers: ContainerConfig[] = []
118
+
119
+ for (const entry of entries) {
120
+ if (entry.isDirectory()) {
121
+ const config = await this.getConfig(entry.name)
122
+ if (config) {
123
+ // Check if actually running
124
+ const running = await processManager.isRunning(entry.name)
125
+ containers.push({
126
+ ...config,
127
+ status: running ? 'running' : 'stopped',
128
+ })
129
+ }
130
+ }
131
+ }
132
+
133
+ return containers
134
+ }
135
+
136
+ /**
137
+ * Delete a container
138
+ */
139
+ async delete(name: string, options: DeleteOptions = {}): Promise<void> {
140
+ const { force = false } = options
141
+
142
+ if (!(await this.exists(name))) {
143
+ throw new Error(`Container "${name}" not found`)
144
+ }
145
+
146
+ // Check if running
147
+ const running = await processManager.isRunning(name)
148
+ if (running && !force) {
149
+ throw new Error(
150
+ `Container "${name}" is running. Stop it first or use --force`,
151
+ )
152
+ }
153
+
154
+ const containerPath = paths.getContainerPath(name)
155
+ await rm(containerPath, { recursive: true, force: true })
156
+ }
157
+
158
+ /**
159
+ * Clone a container
160
+ */
161
+ async clone(
162
+ sourceName: string,
163
+ targetName: string,
164
+ ): Promise<ContainerConfig> {
165
+ // Validate target name
166
+ if (!this.isValidName(targetName)) {
167
+ throw new Error(
168
+ 'Container name must be alphanumeric with hyphens/underscores only',
169
+ )
170
+ }
171
+
172
+ // Check source exists
173
+ if (!(await this.exists(sourceName))) {
174
+ throw new Error(`Source container "${sourceName}" not found`)
175
+ }
176
+
177
+ // Check target doesn't exist
178
+ if (await this.exists(targetName)) {
179
+ throw new Error(`Target container "${targetName}" already exists`)
180
+ }
181
+
182
+ // Check source is not running
183
+ const running = await processManager.isRunning(sourceName)
184
+ if (running) {
185
+ throw new Error(
186
+ `Source container "${sourceName}" is running. Stop it first`,
187
+ )
188
+ }
189
+
190
+ // Copy container directory
191
+ const sourcePath = paths.getContainerPath(sourceName)
192
+ const targetPath = paths.getContainerPath(targetName)
193
+
194
+ await cp(sourcePath, targetPath, { recursive: true })
195
+
196
+ // Update target config
197
+ const config = await this.getConfig(targetName)
198
+ if (!config) {
199
+ throw new Error('Failed to read cloned container config')
200
+ }
201
+
202
+ config.name = targetName
203
+ config.created = new Date().toISOString()
204
+ config.clonedFrom = sourceName
205
+
206
+ // Assign new port
207
+ const { port } = await portManager.findAvailablePort()
208
+ config.port = port
209
+
210
+ await this.saveConfig(targetName, config)
211
+
212
+ return config
213
+ }
214
+
215
+ /**
216
+ * Validate container name
217
+ */
218
+ isValidName(name: string): boolean {
219
+ return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
220
+ }
221
+
222
+ /**
223
+ * Get connection string for a container
224
+ */
225
+ getConnectionString(
226
+ config: ContainerConfig,
227
+ database: string = 'postgres',
228
+ ): string {
229
+ const { port } = config
230
+ return `postgresql://postgres@localhost:${port}/${database}`
231
+ }
232
+ }
233
+
234
+ export const containerManager = new ContainerManager()
@@ -0,0 +1,84 @@
1
+ import net from 'net'
2
+ import { exec } from 'child_process'
3
+ import { promisify } from 'util'
4
+ import { defaults } from '@/config/defaults'
5
+ import type { PortResult } from '@/types'
6
+
7
+ const execAsync = promisify(exec)
8
+
9
+ export class PortManager {
10
+ /**
11
+ * Check if a specific port is available
12
+ */
13
+ async isPortAvailable(port: number): Promise<boolean> {
14
+ return new Promise((resolve) => {
15
+ const server = net.createServer()
16
+
17
+ server.once('error', (err: NodeJS.ErrnoException) => {
18
+ if (err.code === 'EADDRINUSE') {
19
+ resolve(false)
20
+ } else {
21
+ // Other errors - assume port is available
22
+ resolve(true)
23
+ }
24
+ })
25
+
26
+ server.once('listening', () => {
27
+ server.close()
28
+ resolve(true)
29
+ })
30
+
31
+ server.listen(port, '127.0.0.1')
32
+ })
33
+ }
34
+
35
+ /**
36
+ * Find the next available port starting from the default
37
+ * Returns the port number and whether it's the default port
38
+ */
39
+ async findAvailablePort(
40
+ preferredPort: number = defaults.port,
41
+ ): Promise<PortResult> {
42
+ // First try the preferred port
43
+ if (await this.isPortAvailable(preferredPort)) {
44
+ return {
45
+ port: preferredPort,
46
+ isDefault: preferredPort === defaults.port,
47
+ }
48
+ }
49
+
50
+ // Scan for available ports in the range
51
+ for (
52
+ let port = defaults.portRange.start;
53
+ port <= defaults.portRange.end;
54
+ port++
55
+ ) {
56
+ if (port === preferredPort) continue // Already tried this one
57
+
58
+ if (await this.isPortAvailable(port)) {
59
+ return {
60
+ port,
61
+ isDefault: false,
62
+ }
63
+ }
64
+ }
65
+
66
+ throw new Error(
67
+ `No available ports found in range ${defaults.portRange.start}-${defaults.portRange.end}`,
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Get what's using a specific port (for diagnostics)
73
+ */
74
+ async getPortUser(port: number): Promise<string | null> {
75
+ try {
76
+ const { stdout } = await execAsync(`lsof -i :${port} -P -n | head -5`)
77
+ return stdout.trim()
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+ }
83
+
84
+ export const portManager = new PortManager()
@@ -0,0 +1,353 @@
1
+ import { exec, spawn } from 'child_process'
2
+ import { promisify } from 'util'
3
+ import { existsSync } from 'fs'
4
+ import { readFile } from 'fs/promises'
5
+ import { paths } from '@/config/paths'
6
+ import type { ProcessResult, StatusResult } from '@/types'
7
+
8
+ const execAsync = promisify(exec)
9
+
10
+ export interface InitdbOptions {
11
+ superuser?: string
12
+ }
13
+
14
+ export interface StartOptions {
15
+ port?: number
16
+ logFile?: string
17
+ }
18
+
19
+ export interface PsqlOptions {
20
+ port: number
21
+ database?: string
22
+ user?: string
23
+ command?: string
24
+ }
25
+
26
+ export interface PgRestoreOptions {
27
+ port: number
28
+ database: string
29
+ user?: string
30
+ format?: string
31
+ }
32
+
33
+ export class ProcessManager {
34
+ /**
35
+ * Initialize a new PostgreSQL data directory
36
+ */
37
+ async initdb(
38
+ initdbPath: string,
39
+ dataDir: string,
40
+ options: InitdbOptions = {},
41
+ ): Promise<ProcessResult> {
42
+ const { superuser = 'postgres' } = options
43
+
44
+ const args = [
45
+ '-D',
46
+ dataDir,
47
+ '-U',
48
+ superuser,
49
+ '--auth=trust',
50
+ '--encoding=UTF8',
51
+ '--no-locale',
52
+ ]
53
+
54
+ return new Promise((resolve, reject) => {
55
+ const proc = spawn(initdbPath, args, {
56
+ stdio: ['ignore', 'pipe', 'pipe'],
57
+ })
58
+
59
+ let stdout = ''
60
+ let stderr = ''
61
+
62
+ proc.stdout.on('data', (data: Buffer) => {
63
+ stdout += data.toString()
64
+ })
65
+ proc.stderr.on('data', (data: Buffer) => {
66
+ stderr += data.toString()
67
+ })
68
+
69
+ proc.on('close', (code) => {
70
+ if (code === 0) {
71
+ resolve({ stdout, stderr })
72
+ } else {
73
+ reject(new Error(`initdb failed with code ${code}: ${stderr}`))
74
+ }
75
+ })
76
+
77
+ proc.on('error', reject)
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Start PostgreSQL server using pg_ctl
83
+ */
84
+ async start(
85
+ pgCtlPath: string,
86
+ dataDir: string,
87
+ options: StartOptions = {},
88
+ ): Promise<ProcessResult> {
89
+ const { port, logFile } = options
90
+
91
+ const pgOptions: string[] = []
92
+ if (port) {
93
+ pgOptions.push(`-p ${port}`)
94
+ }
95
+
96
+ const args = [
97
+ 'start',
98
+ '-D',
99
+ dataDir,
100
+ '-l',
101
+ logFile || '/dev/null',
102
+ '-w', // Wait for startup to complete
103
+ '-o',
104
+ pgOptions.join(' '),
105
+ ]
106
+
107
+ return new Promise((resolve, reject) => {
108
+ const proc = spawn(pgCtlPath, args, {
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ })
111
+
112
+ let stdout = ''
113
+ let stderr = ''
114
+
115
+ proc.stdout.on('data', (data: Buffer) => {
116
+ stdout += data.toString()
117
+ })
118
+ proc.stderr.on('data', (data: Buffer) => {
119
+ stderr += data.toString()
120
+ })
121
+
122
+ proc.on('close', (code) => {
123
+ if (code === 0) {
124
+ resolve({ stdout, stderr })
125
+ } else {
126
+ reject(
127
+ new Error(
128
+ `pg_ctl start failed with code ${code}: ${stderr || stdout}`,
129
+ ),
130
+ )
131
+ }
132
+ })
133
+
134
+ proc.on('error', reject)
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Stop PostgreSQL server using pg_ctl
140
+ */
141
+ async stop(pgCtlPath: string, dataDir: string): Promise<ProcessResult> {
142
+ const args = [
143
+ 'stop',
144
+ '-D',
145
+ dataDir,
146
+ '-m',
147
+ 'fast',
148
+ '-w', // Wait for shutdown to complete
149
+ ]
150
+
151
+ return new Promise((resolve, reject) => {
152
+ const proc = spawn(pgCtlPath, args, {
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ })
155
+
156
+ let stdout = ''
157
+ let stderr = ''
158
+
159
+ proc.stdout.on('data', (data: Buffer) => {
160
+ stdout += data.toString()
161
+ })
162
+ proc.stderr.on('data', (data: Buffer) => {
163
+ stderr += data.toString()
164
+ })
165
+
166
+ proc.on('close', (code) => {
167
+ if (code === 0) {
168
+ resolve({ stdout, stderr })
169
+ } else {
170
+ reject(
171
+ new Error(
172
+ `pg_ctl stop failed with code ${code}: ${stderr || stdout}`,
173
+ ),
174
+ )
175
+ }
176
+ })
177
+
178
+ proc.on('error', reject)
179
+ })
180
+ }
181
+
182
+ /**
183
+ * Get PostgreSQL server status
184
+ */
185
+ async status(pgCtlPath: string, dataDir: string): Promise<StatusResult> {
186
+ const args = ['status', '-D', dataDir]
187
+
188
+ try {
189
+ const { stdout } = await execAsync(`"${pgCtlPath}" ${args.join(' ')}`)
190
+ return {
191
+ running: true,
192
+ message: stdout.trim(),
193
+ }
194
+ } catch (error) {
195
+ // pg_ctl status returns non-zero if server is not running
196
+ const err = error as { stderr?: string; message: string }
197
+ return {
198
+ running: false,
199
+ message: err.stderr?.trim() || err.message,
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Check if PostgreSQL is running by looking for PID file
206
+ */
207
+ async isRunning(containerName: string): Promise<boolean> {
208
+ const pidFile = paths.getContainerPidPath(containerName)
209
+ if (!existsSync(pidFile)) {
210
+ return false
211
+ }
212
+
213
+ try {
214
+ const content = await readFile(pidFile, 'utf8')
215
+ const pid = parseInt(content.split('\n')[0], 10)
216
+
217
+ // Check if process is still running
218
+ process.kill(pid, 0)
219
+ return true
220
+ } catch {
221
+ return false
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Get the PID of a running PostgreSQL server
227
+ */
228
+ async getPid(containerName: string): Promise<number | null> {
229
+ const pidFile = paths.getContainerPidPath(containerName)
230
+ if (!existsSync(pidFile)) {
231
+ return null
232
+ }
233
+
234
+ try {
235
+ const content = await readFile(pidFile, 'utf8')
236
+ return parseInt(content.split('\n')[0], 10)
237
+ } catch {
238
+ return null
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Execute psql command
244
+ */
245
+ async psql(
246
+ psqlPath: string,
247
+ options: PsqlOptions,
248
+ ): Promise<ProcessResult & { code?: number }> {
249
+ const { port, database = 'postgres', user = 'postgres', command } = options
250
+
251
+ const args = [
252
+ '-h',
253
+ '127.0.0.1',
254
+ '-p',
255
+ String(port),
256
+ '-U',
257
+ user,
258
+ '-d',
259
+ database,
260
+ ]
261
+
262
+ if (command) {
263
+ args.push('-c', command)
264
+ }
265
+
266
+ return new Promise((resolve, reject) => {
267
+ const proc = spawn(psqlPath, args, {
268
+ stdio: command ? ['ignore', 'pipe', 'pipe'] : 'inherit',
269
+ })
270
+
271
+ if (command) {
272
+ let stdout = ''
273
+ let stderr = ''
274
+
275
+ proc.stdout?.on('data', (data: Buffer) => {
276
+ stdout += data.toString()
277
+ })
278
+ proc.stderr?.on('data', (data: Buffer) => {
279
+ stderr += data.toString()
280
+ })
281
+
282
+ proc.on('close', (code) => {
283
+ if (code === 0) {
284
+ resolve({ stdout, stderr, code: code ?? undefined })
285
+ } else {
286
+ reject(new Error(`psql failed with code ${code}: ${stderr}`))
287
+ }
288
+ })
289
+ } else {
290
+ proc.on('close', (code) => {
291
+ resolve({ stdout: '', stderr: '', code: code ?? undefined })
292
+ })
293
+ }
294
+
295
+ proc.on('error', reject)
296
+ })
297
+ }
298
+
299
+ /**
300
+ * Execute pg_restore command
301
+ */
302
+ async pgRestore(
303
+ pgRestorePath: string,
304
+ backupFile: string,
305
+ options: PgRestoreOptions,
306
+ ): Promise<ProcessResult & { code?: number }> {
307
+ const { port, database, user = 'postgres', format } = options
308
+
309
+ const args = [
310
+ '-h',
311
+ '127.0.0.1',
312
+ '-p',
313
+ String(port),
314
+ '-U',
315
+ user,
316
+ '-d',
317
+ database,
318
+ '--no-owner',
319
+ '--no-privileges',
320
+ ]
321
+
322
+ if (format) {
323
+ args.push('-F', format)
324
+ }
325
+
326
+ args.push(backupFile)
327
+
328
+ return new Promise((resolve, reject) => {
329
+ const proc = spawn(pgRestorePath, args, {
330
+ stdio: ['ignore', 'pipe', 'pipe'],
331
+ })
332
+
333
+ let stdout = ''
334
+ let stderr = ''
335
+
336
+ proc.stdout.on('data', (data: Buffer) => {
337
+ stdout += data.toString()
338
+ })
339
+ proc.stderr.on('data', (data: Buffer) => {
340
+ stderr += data.toString()
341
+ })
342
+
343
+ proc.on('close', (code) => {
344
+ // pg_restore may return non-zero even on partial success
345
+ resolve({ stdout, stderr, code: code ?? undefined })
346
+ })
347
+
348
+ proc.on('error', reject)
349
+ })
350
+ }
351
+ }
352
+
353
+ export const processManager = new ProcessManager()