spindb 0.15.2 → 0.17.2

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 (112) hide show
  1. package/README.md +176 -81
  2. package/bin/cli.js +39 -10
  3. package/cli/commands/backup.ts +4 -1
  4. package/cli/commands/backups.ts +17 -23
  5. package/cli/commands/config.ts +1 -3
  6. package/cli/commands/connect.ts +16 -2
  7. package/cli/commands/create.ts +18 -9
  8. package/cli/commands/deps.ts +1 -3
  9. package/cli/commands/doctor.ts +29 -16
  10. package/cli/commands/edit.ts +4 -12
  11. package/cli/commands/engines.ts +730 -52
  12. package/cli/commands/list.ts +1 -3
  13. package/cli/commands/menu/backup-handlers.ts +36 -39
  14. package/cli/commands/menu/container-handlers.ts +6 -10
  15. package/cli/commands/menu/engine-handlers.ts +71 -639
  16. package/cli/commands/menu/shared.ts +2 -6
  17. package/cli/commands/menu/shell-handlers.ts +63 -16
  18. package/cli/commands/menu/sql-handlers.ts +10 -7
  19. package/cli/commands/run.ts +7 -2
  20. package/cli/commands/start.ts +10 -7
  21. package/cli/commands/stop.ts +9 -4
  22. package/cli/constants.ts +2 -1
  23. package/cli/helpers.ts +358 -357
  24. package/cli/ui/prompts.ts +8 -16
  25. package/cli/ui/spinner.ts +3 -9
  26. package/cli/utils/file-follower.ts +1 -3
  27. package/config/backup-formats.ts +42 -27
  28. package/config/engine-defaults.ts +25 -15
  29. package/config/engines-registry.ts +93 -0
  30. package/config/engines.json +109 -0
  31. package/config/engines.schema.json +117 -0
  32. package/config/os-dependencies.ts +72 -30
  33. package/config/paths.ts +7 -21
  34. package/core/backup-restore.ts +16 -32
  35. package/core/binary-manager.ts +165 -19
  36. package/core/config-manager.ts +187 -14
  37. package/core/dependency-manager.ts +13 -46
  38. package/core/error-handler.ts +1 -3
  39. package/core/homebrew-version-manager.ts +22 -22
  40. package/core/hostdb-client.ts +173 -0
  41. package/core/hostdb-metadata.ts +325 -0
  42. package/core/platform-service.ts +16 -72
  43. package/core/process-manager.ts +3 -1
  44. package/core/spawn-utils.ts +120 -0
  45. package/core/start-with-retry.ts +1 -3
  46. package/core/transaction-manager.ts +2 -6
  47. package/core/update-manager.ts +5 -15
  48. package/core/version-utils.ts +77 -0
  49. package/engines/base-engine.ts +29 -42
  50. package/engines/index.ts +6 -9
  51. package/engines/mariadb/backup.ts +8 -10
  52. package/engines/mariadb/binary-manager.ts +58 -138
  53. package/engines/mariadb/binary-urls.ts +3 -12
  54. package/engines/mariadb/hostdb-releases.ts +95 -180
  55. package/engines/mariadb/index.ts +24 -17
  56. package/engines/mariadb/restore.ts +10 -8
  57. package/engines/mariadb/version-maps.ts +21 -15
  58. package/engines/mariadb/version-validator.ts +12 -24
  59. package/engines/mongodb/backup.ts +2 -6
  60. package/engines/mongodb/binary-manager.ts +435 -0
  61. package/engines/mongodb/binary-urls.ts +71 -0
  62. package/engines/mongodb/cli-utils.ts +78 -0
  63. package/engines/mongodb/hostdb-releases.ts +182 -0
  64. package/engines/mongodb/index.ts +217 -152
  65. package/engines/mongodb/restore.ts +8 -16
  66. package/engines/mongodb/version-maps.ts +89 -0
  67. package/engines/mongodb/version-validator.ts +2 -6
  68. package/engines/mysql/backup.ts +13 -11
  69. package/engines/mysql/binary-detection.ts +116 -108
  70. package/engines/mysql/binary-manager.ts +395 -0
  71. package/engines/mysql/binary-urls.ts +122 -0
  72. package/engines/mysql/hostdb-releases.ts +199 -0
  73. package/engines/mysql/index.ts +356 -507
  74. package/engines/mysql/restore.ts +47 -13
  75. package/engines/mysql/version-maps.ts +88 -0
  76. package/engines/mysql/version-validator.ts +2 -6
  77. package/engines/postgresql/backup.ts +1 -3
  78. package/engines/postgresql/binary-manager.ts +63 -36
  79. package/engines/postgresql/binary-urls.ts +4 -15
  80. package/engines/postgresql/hostdb-releases.ts +101 -170
  81. package/engines/postgresql/index.ts +45 -70
  82. package/engines/postgresql/remote-version.ts +1 -3
  83. package/engines/postgresql/restore.ts +4 -12
  84. package/engines/postgresql/version-maps.ts +31 -8
  85. package/engines/postgresql/version-validator.ts +1 -3
  86. package/engines/redis/backup.ts +131 -100
  87. package/engines/redis/binary-manager.ts +471 -0
  88. package/engines/redis/binary-urls.ts +140 -0
  89. package/engines/redis/cli-utils.ts +44 -0
  90. package/engines/redis/hostdb-releases.ts +177 -0
  91. package/engines/redis/index.ts +285 -197
  92. package/engines/redis/restore.ts +54 -23
  93. package/engines/redis/version-maps.ts +80 -0
  94. package/engines/redis/version-validator.ts +1 -3
  95. package/engines/sqlite/binary-manager.ts +431 -0
  96. package/engines/sqlite/binary-urls.ts +120 -0
  97. package/engines/sqlite/index.ts +165 -64
  98. package/engines/sqlite/registry.ts +9 -27
  99. package/engines/sqlite/version-maps.ts +63 -0
  100. package/engines/valkey/backup.ts +389 -0
  101. package/engines/valkey/binary-manager.ts +473 -0
  102. package/engines/valkey/binary-urls.ts +143 -0
  103. package/engines/valkey/cli-utils.ts +44 -0
  104. package/engines/valkey/hostdb-releases.ts +182 -0
  105. package/engines/valkey/index.ts +1022 -0
  106. package/engines/valkey/restore.ts +415 -0
  107. package/engines/valkey/version-maps.ts +80 -0
  108. package/engines/valkey/version-validator.ts +131 -0
  109. package/package.json +11 -2
  110. package/types/index.ts +103 -21
  111. package/engines/mongodb/binary-detection.ts +0 -314
  112. package/engines/redis/binary-detection.ts +0 -442
@@ -0,0 +1,1022 @@
1
+ import { spawn, exec, type SpawnOptions } from 'child_process'
2
+ import { promisify } from 'util'
3
+ import { existsSync } from 'fs'
4
+ import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
5
+ import { join } from 'path'
6
+ import { BaseEngine } from '../base-engine'
7
+ import { paths } from '../../config/paths'
8
+ import { getEngineDefaults } from '../../config/defaults'
9
+ import { platformService, isWindows } from '../../core/platform-service'
10
+ import { configManager } from '../../core/config-manager'
11
+ import { logDebug, logWarning } from '../../core/error-handler'
12
+ import { processManager } from '../../core/process-manager'
13
+ import { valkeyBinaryManager } from './binary-manager'
14
+ import { getBinaryUrl, VERSION_MAP } from './binary-urls'
15
+ import { normalizeVersion, SUPPORTED_MAJOR_VERSIONS } from './version-maps'
16
+ import { fetchAvailableVersions as fetchHostdbVersions } from './hostdb-releases'
17
+ import {
18
+ detectBackupFormat as detectBackupFormatImpl,
19
+ restoreBackup,
20
+ } from './restore'
21
+ import { createBackup } from './backup'
22
+ import type {
23
+ ContainerConfig,
24
+ ProgressCallback,
25
+ BackupFormat,
26
+ BackupOptions,
27
+ BackupResult,
28
+ RestoreResult,
29
+ DumpResult,
30
+ StatusResult,
31
+ } from '../../types'
32
+
33
+ const execAsync = promisify(exec)
34
+
35
+ const ENGINE = 'valkey'
36
+ const engineDef = getEngineDefaults(ENGINE)
37
+
38
+ /**
39
+ * Shell metacharacters that indicate potential command injection
40
+ * These patterns shouldn't appear in valid Valkey commands
41
+ */
42
+ const SHELL_INJECTION_PATTERNS = [
43
+ /;\s*\S/, // Command chaining: ; followed by another command
44
+ /\$\(/, // Command substitution: $(...)
45
+ /\$\{/, // Variable substitution: ${...}
46
+ /`/, // Backtick command substitution
47
+ /&&/, // Logical AND chaining
48
+ /\|\|/, // Logical OR chaining
49
+ /\|\s*\S/, // Pipe to another command
50
+ ]
51
+
52
+ // Validate that a command doesn't contain shell injection patterns
53
+ function validateCommand(command: string): void {
54
+ for (const pattern of SHELL_INJECTION_PATTERNS) {
55
+ if (pattern.test(command)) {
56
+ throw new Error(
57
+ `Command contains shell metacharacters that are not valid in Valkey commands. ` +
58
+ `If you need complex commands, use a script file instead.`,
59
+ )
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Convert a Windows path to Cygwin path format.
66
+ * Valkey Windows binaries are built with Cygwin runtime and expect paths
67
+ * in /cygdrive/c/... format when passed as command-line arguments.
68
+ *
69
+ * Example: C:\Users\foo\config.conf -> /cygdrive/c/Users/foo/config.conf
70
+ */
71
+ function toCygwinPath(windowsPath: string): string {
72
+ // Match drive letter at start (e.g., C:\ or D:/)
73
+ const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\]/)
74
+ if (!driveMatch) {
75
+ // Not a Windows absolute path, return as-is with forward slashes
76
+ return windowsPath.replace(/\\/g, '/')
77
+ }
78
+
79
+ const driveLetter = driveMatch[1].toLowerCase()
80
+ const restOfPath = windowsPath.slice(3).replace(/\\/g, '/')
81
+ return `/cygdrive/${driveLetter}/${restOfPath}`
82
+ }
83
+
84
+ // Build a valkey-cli command for inline command execution
85
+ export function buildValkeyCliCommand(
86
+ valkeyCliPath: string,
87
+ port: number,
88
+ command: string,
89
+ options?: { database?: string },
90
+ ): string {
91
+ // Validate command doesn't contain shell injection patterns
92
+ validateCommand(command)
93
+
94
+ const db = options?.database || '0'
95
+ // Escape double quotes consistently on all platforms to prevent shell interpretation issues
96
+ const escaped = command.replace(/"/g, '\\"')
97
+ return `"${valkeyCliPath}" -h 127.0.0.1 -p ${port} -n ${db} ${escaped}`
98
+ }
99
+
100
+ // Generate Valkey configuration file content
101
+ function generateValkeyConfig(options: {
102
+ port: number
103
+ dataDir: string
104
+ logFile: string
105
+ pidFile: string
106
+ daemonize?: boolean
107
+ }): string {
108
+ // Windows Valkey doesn't support daemonize natively, use detached spawn instead
109
+ const daemonizeValue = options.daemonize ?? true
110
+
111
+ // Valkey config requires forward slashes even on Windows
112
+ const normalizePathForValkey = (p: string) => p.replace(/\\/g, '/')
113
+
114
+ return `# SpinDB generated Valkey configuration
115
+ port ${options.port}
116
+ bind 127.0.0.1
117
+ dir ${normalizePathForValkey(options.dataDir)}
118
+ daemonize ${daemonizeValue ? 'yes' : 'no'}
119
+ logfile ${normalizePathForValkey(options.logFile)}
120
+ pidfile ${normalizePathForValkey(options.pidFile)}
121
+
122
+ # Persistence - RDB snapshots
123
+ save 900 1
124
+ save 300 10
125
+ save 60 10000
126
+ dbfilename dump.rdb
127
+
128
+ # Append Only File (disabled for local dev)
129
+ appendonly no
130
+ `
131
+ }
132
+
133
+ export class ValkeyEngine extends BaseEngine {
134
+ name = ENGINE
135
+ displayName = 'Valkey'
136
+ defaultPort = engineDef.defaultPort
137
+ supportedVersions = SUPPORTED_MAJOR_VERSIONS
138
+
139
+ // Get platform info for binary operations
140
+ getPlatformInfo(): { platform: string; arch: string } {
141
+ return platformService.getPlatformInfo()
142
+ }
143
+
144
+ // Fetch available versions from hostdb (dynamically or from cache/fallback)
145
+ async fetchAvailableVersions(): Promise<Record<string, string[]>> {
146
+ return fetchHostdbVersions()
147
+ }
148
+
149
+ // Get binary download URL from hostdb
150
+ getBinaryUrl(version: string, platform: string, arch: string): string {
151
+ return getBinaryUrl(version, platform, arch)
152
+ }
153
+
154
+ // Resolves version string to full version (e.g., '8' -> '8.0.6')
155
+ resolveFullVersion(version: string): string {
156
+ // Check if already a full version (has at least two dots)
157
+ if (/^\d+\.\d+\.\d+$/.test(version)) {
158
+ return version
159
+ }
160
+ // It's a major version, resolve using version map
161
+ return VERSION_MAP[version] || `${version}.0.0`
162
+ }
163
+
164
+ // Get the path where binaries for a version would be installed
165
+ getBinaryPath(version: string): string {
166
+ const fullVersion = this.resolveFullVersion(version)
167
+ const { platform: p, arch: a } = this.getPlatformInfo()
168
+ return paths.getBinaryPath({
169
+ engine: 'valkey',
170
+ version: fullVersion,
171
+ platform: p,
172
+ arch: a,
173
+ })
174
+ }
175
+
176
+ // Verify that Valkey binaries are available
177
+ async verifyBinary(binPath: string): Promise<boolean> {
178
+ const ext = platformService.getExecutableExtension()
179
+ const serverPath = join(binPath, 'bin', `valkey-server${ext}`)
180
+ return existsSync(serverPath)
181
+ }
182
+
183
+ //Check if a specific Valkey version is installed (downloaded)
184
+ async isBinaryInstalled(version: string): Promise<boolean> {
185
+ const { platform, arch } = this.getPlatformInfo()
186
+ return valkeyBinaryManager.isInstalled(version, platform, arch)
187
+ }
188
+
189
+ /**
190
+ * Ensure Valkey binaries are available for a specific version
191
+ * Downloads from hostdb if not already installed
192
+ * Returns the path to the bin directory
193
+ */
194
+ async ensureBinaries(
195
+ version: string,
196
+ onProgress?: ProgressCallback,
197
+ ): Promise<string> {
198
+ const { platform, arch } = this.getPlatformInfo()
199
+
200
+ const binPath = await valkeyBinaryManager.ensureInstalled(
201
+ version,
202
+ platform,
203
+ arch,
204
+ onProgress,
205
+ )
206
+
207
+ // Register binaries in config
208
+ const ext = platformService.getExecutableExtension()
209
+ const tools = ['valkey-server', 'valkey-cli'] as const
210
+
211
+ for (const tool of tools) {
212
+ const toolPath = join(binPath, 'bin', `${tool}${ext}`)
213
+ if (existsSync(toolPath)) {
214
+ await configManager.setBinaryPath(tool, toolPath, 'bundled')
215
+ }
216
+ }
217
+
218
+ return binPath
219
+ }
220
+
221
+ /**
222
+ * Initialize a new Valkey data directory
223
+ * Creates the directory and generates valkey.conf
224
+ */
225
+ async initDataDir(
226
+ containerName: string,
227
+ _version: string,
228
+ options: Record<string, unknown> = {},
229
+ ): Promise<string> {
230
+ const dataDir = paths.getContainerDataPath(containerName, {
231
+ engine: ENGINE,
232
+ })
233
+ const containerDir = paths.getContainerPath(containerName, {
234
+ engine: ENGINE,
235
+ })
236
+ const logFile = paths.getContainerLogPath(containerName, { engine: ENGINE })
237
+ const pidFile = join(containerDir, 'valkey.pid')
238
+ const port = (options.port as number) || engineDef.defaultPort
239
+
240
+ // Create data directory if it doesn't exist
241
+ if (!existsSync(dataDir)) {
242
+ await mkdir(dataDir, { recursive: true })
243
+ logDebug(`Created Valkey data directory: ${dataDir}`)
244
+ }
245
+
246
+ // Generate valkey.conf
247
+ const configPath = join(containerDir, 'valkey.conf')
248
+ const configContent = generateValkeyConfig({
249
+ port,
250
+ dataDir,
251
+ logFile,
252
+ pidFile,
253
+ })
254
+ await writeFile(configPath, configContent)
255
+ logDebug(`Generated Valkey config: ${configPath}`)
256
+
257
+ return dataDir
258
+ }
259
+
260
+ // Get the path to valkey-server for a version
261
+ async getValkeyServerPath(version: string): Promise<string> {
262
+ const { platform, arch } = this.getPlatformInfo()
263
+ const fullVersion = normalizeVersion(version)
264
+ const binPath = paths.getBinaryPath({
265
+ engine: 'valkey',
266
+ version: fullVersion,
267
+ platform,
268
+ arch,
269
+ })
270
+ const ext = platformService.getExecutableExtension()
271
+ const serverPath = join(binPath, 'bin', `valkey-server${ext}`)
272
+ if (existsSync(serverPath)) {
273
+ return serverPath
274
+ }
275
+ throw new Error(
276
+ `Valkey ${version} is not installed. Run: spindb engines download valkey ${version}`,
277
+ )
278
+ }
279
+
280
+ // Get the path to valkey-cli for a version
281
+ override async getValkeyCliPath(version?: string): Promise<string> {
282
+ // Check config cache first
283
+ const cached = await configManager.getBinaryPath('valkey-cli')
284
+ if (cached && existsSync(cached)) {
285
+ return cached
286
+ }
287
+
288
+ // If version provided, use downloaded binary
289
+ if (version) {
290
+ const { platform, arch } = this.getPlatformInfo()
291
+ const fullVersion = normalizeVersion(version)
292
+ const binPath = paths.getBinaryPath({
293
+ engine: 'valkey',
294
+ version: fullVersion,
295
+ platform,
296
+ arch,
297
+ })
298
+ const ext = platformService.getExecutableExtension()
299
+ const cliPath = join(binPath, 'bin', `valkey-cli${ext}`)
300
+ if (existsSync(cliPath)) {
301
+ return cliPath
302
+ }
303
+ }
304
+
305
+ throw new Error(
306
+ 'valkey-cli not found. Run: spindb engines download valkey <version>',
307
+ )
308
+ }
309
+
310
+ /**
311
+ * Start Valkey server
312
+ * CLI wrapper: valkey-server /path/to/valkey.conf
313
+ */
314
+ async start(
315
+ container: ContainerConfig,
316
+ onProgress?: ProgressCallback,
317
+ ): Promise<{ port: number; connectionString: string }> {
318
+ const { name, port, version, binaryPath } = container
319
+
320
+ // Check if already running (idempotent behavior)
321
+ const alreadyRunning = await processManager.isRunning(name, {
322
+ engine: ENGINE,
323
+ })
324
+ if (alreadyRunning) {
325
+ return {
326
+ port,
327
+ connectionString: this.getConnectionString(container),
328
+ }
329
+ }
330
+
331
+ // Use stored binary path if available (from container creation)
332
+ // This ensures version consistency - the container uses the same binary it was created with
333
+ let valkeyServer: string | null = null
334
+
335
+ if (binaryPath && existsSync(binaryPath)) {
336
+ // binaryPath is the directory (e.g., ~/.spindb/bin/valkey-8.0.6-linux-arm64)
337
+ // We need to construct the full path to valkey-server
338
+ const ext = platformService.getExecutableExtension()
339
+ const serverPath = join(binaryPath, 'bin', `valkey-server${ext}`)
340
+ if (existsSync(serverPath)) {
341
+ valkeyServer = serverPath
342
+ logDebug(`Using stored binary path: ${valkeyServer}`)
343
+ }
344
+ }
345
+
346
+ // If we didn't find the binary above, fall back to normal path
347
+ if (!valkeyServer) {
348
+ // Get binary from downloaded hostdb binaries
349
+ try {
350
+ valkeyServer = await this.getValkeyServerPath(version)
351
+ } catch (error) {
352
+ // Binary not downloaded yet - this is an orphaned container situation
353
+ const originalMessage =
354
+ error instanceof Error ? error.message : String(error)
355
+ throw new Error(
356
+ `Valkey ${version} is not installed. Run: spindb engines download valkey ${version}\n` +
357
+ ` Original error: ${originalMessage}`,
358
+ )
359
+ }
360
+ }
361
+
362
+ logDebug(`Using valkey-server for version ${version}: ${valkeyServer}`)
363
+
364
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
365
+ const configPath = join(containerDir, 'valkey.conf')
366
+ const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
367
+ const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
368
+ const pidFile = join(containerDir, 'valkey.pid')
369
+
370
+ // Windows Valkey doesn't support daemonize natively
371
+ // Use detached spawn on Windows instead, similar to MongoDB
372
+ const useDetachedSpawn = isWindows()
373
+
374
+ // Regenerate config with current port (in case it changed)
375
+ const configContent = generateValkeyConfig({
376
+ port,
377
+ dataDir,
378
+ logFile,
379
+ pidFile,
380
+ daemonize: !useDetachedSpawn, // Disable daemonize on Windows
381
+ })
382
+ await writeFile(configPath, configContent)
383
+
384
+ onProgress?.({ stage: 'starting', message: 'Starting Valkey...' })
385
+
386
+ logDebug(`Starting valkey-server with config: ${configPath}`)
387
+
388
+ /**
389
+ * Check log file for port binding errors
390
+ * Returns error message if found, null otherwise
391
+ */
392
+ const checkLogForPortError = async (): Promise<string | null> => {
393
+ try {
394
+ const logContent = await readFile(logFile, 'utf-8')
395
+ const recentLog = logContent.slice(-2000) // Last 2KB
396
+
397
+ if (
398
+ recentLog.includes('Address already in use') ||
399
+ recentLog.includes('bind: Address already in use')
400
+ ) {
401
+ return `Port ${port} is already in use (address already in use)`
402
+ }
403
+ if (recentLog.includes('Failed listening on port')) {
404
+ return `Port ${port} is already in use`
405
+ }
406
+ } catch {
407
+ // Log file might not exist yet
408
+ }
409
+ return null
410
+ }
411
+
412
+ if (useDetachedSpawn) {
413
+ // Windows: spawn detached process with proper error handling
414
+ // This follows the pattern used by MySQL which works on Windows
415
+ return new Promise((resolve, reject) => {
416
+ const spawnOpts: SpawnOptions = {
417
+ stdio: ['ignore', 'pipe', 'pipe'],
418
+ detached: true,
419
+ windowsHide: true,
420
+ }
421
+
422
+ // Convert Windows path to Cygwin format for Cygwin-built binaries
423
+ const cygwinConfigPath = toCygwinPath(configPath)
424
+ const proc = spawn(valkeyServer, [cygwinConfigPath], spawnOpts)
425
+ let settled = false
426
+ let stderrOutput = ''
427
+ let stdoutOutput = ''
428
+
429
+ // Handle spawn errors (binary not found, DLL issues, etc.)
430
+ proc.on('error', (err) => {
431
+ if (settled) return
432
+ settled = true
433
+ reject(new Error(`Failed to spawn Valkey server: ${err.message}`))
434
+ })
435
+
436
+ proc.stdout?.on('data', (data: Buffer) => {
437
+ const str = data.toString()
438
+ stdoutOutput += str
439
+ logDebug(`valkey-server stdout: ${str}`)
440
+ })
441
+ proc.stderr?.on('data', (data: Buffer) => {
442
+ const str = data.toString()
443
+ stderrOutput += str
444
+ logDebug(`valkey-server stderr: ${str}`)
445
+ })
446
+
447
+ // Detach the process so it continues running after parent exits
448
+ proc.unref()
449
+
450
+ // Give spawn a moment to fail if it's going to, then check readiness
451
+ setTimeout(async () => {
452
+ if (settled) return
453
+
454
+ // Verify process actually started
455
+ if (!proc.pid) {
456
+ settled = true
457
+ reject(new Error('Valkey server process failed to start (no PID)'))
458
+ return
459
+ }
460
+
461
+ // Write PID file for consistency with other engines
462
+ try {
463
+ await writeFile(pidFile, String(proc.pid))
464
+ } catch {
465
+ // Non-fatal - process is running, PID file is for convenience
466
+ }
467
+
468
+ // Wait for Valkey to be ready
469
+ const ready = await this.waitForReady(port, version)
470
+ if (settled) return
471
+
472
+ if (ready) {
473
+ settled = true
474
+ resolve({
475
+ port,
476
+ connectionString: this.getConnectionString(container),
477
+ })
478
+ } else {
479
+ settled = true
480
+ const portError = await checkLogForPortError()
481
+
482
+ // Read log file content for better error diagnostics
483
+ let logContent = ''
484
+ try {
485
+ logContent = await readFile(logFile, 'utf-8')
486
+ } catch {
487
+ logContent = '(log file not found or empty)'
488
+ }
489
+
490
+ const errorDetails = [
491
+ portError || 'Valkey failed to start within timeout.',
492
+ `Binary: ${valkeyServer}`,
493
+ `Config: ${configPath}`,
494
+ `Log file: ${logFile}`,
495
+ `Log content:\n${logContent || '(empty)'}`,
496
+ stderrOutput ? `Stderr:\n${stderrOutput}` : '',
497
+ stdoutOutput ? `Stdout:\n${stdoutOutput}` : '',
498
+ ]
499
+ .filter(Boolean)
500
+ .join('\n')
501
+
502
+ reject(new Error(errorDetails))
503
+ }
504
+ }, 500)
505
+ })
506
+ }
507
+
508
+ // Unix: Valkey with daemonize: yes handles its own forking
509
+ return new Promise((resolve, reject) => {
510
+ const proc = spawn(valkeyServer, [configPath], {
511
+ stdio: ['ignore', 'pipe', 'pipe'],
512
+ })
513
+
514
+ let stdout = ''
515
+ let stderr = ''
516
+
517
+ proc.stdout?.on('data', (data: Buffer) => {
518
+ stdout += data.toString()
519
+ logDebug(`valkey-server stdout: ${data.toString()}`)
520
+ })
521
+ proc.stderr?.on('data', (data: Buffer) => {
522
+ stderr += data.toString()
523
+ logDebug(`valkey-server stderr: ${data.toString()}`)
524
+ })
525
+
526
+ proc.on('error', reject)
527
+
528
+ proc.on('close', async (code) => {
529
+ // Valkey with daemonize: yes exits immediately after forking
530
+ // Exit code 0 means the parent forked successfully, but the child may still fail
531
+ if (code === 0 || code === null) {
532
+ // Give the child process a moment to start (or fail)
533
+ await new Promise((r) => setTimeout(r, 500))
534
+
535
+ // Check log for early startup failures (like port conflicts)
536
+ const earlyError = await checkLogForPortError()
537
+ if (earlyError) {
538
+ reject(new Error(earlyError))
539
+ return
540
+ }
541
+
542
+ // Wait for Valkey to be ready
543
+ const ready = await this.waitForReady(port, version)
544
+ if (ready) {
545
+ resolve({
546
+ port,
547
+ connectionString: this.getConnectionString(container),
548
+ })
549
+ } else {
550
+ // Check log again for errors if not ready
551
+ const portError = await checkLogForPortError()
552
+ if (portError) {
553
+ reject(new Error(portError))
554
+ return
555
+ }
556
+ reject(
557
+ new Error(
558
+ `Valkey failed to start within timeout. Check logs at: ${logFile}`,
559
+ ),
560
+ )
561
+ }
562
+ } else {
563
+ reject(
564
+ new Error(
565
+ stderr || stdout || `valkey-server exited with code ${code}`,
566
+ ),
567
+ )
568
+ }
569
+ })
570
+ })
571
+ }
572
+
573
+ // Wait for Valkey to be ready to accept connections
574
+ // TODO - consider copying the mongodb logic for this
575
+ private async waitForReady(
576
+ port: number,
577
+ version: string,
578
+ timeoutMs = 30000,
579
+ ): Promise<boolean> {
580
+ const startTime = Date.now()
581
+ const checkInterval = 500
582
+
583
+ let valkeyCli: string
584
+ try {
585
+ valkeyCli = await this.getValkeyCliPathForVersion(version)
586
+ } catch {
587
+ logWarning(
588
+ 'valkey-cli not found, cannot verify Valkey is ready. Assuming ready after brief delay.',
589
+ )
590
+ // Give Valkey a moment to start, then assume success
591
+ await new Promise((resolve) => setTimeout(resolve, 2000))
592
+ return true
593
+ }
594
+
595
+ while (Date.now() - startTime < timeoutMs) {
596
+ try {
597
+ const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} PING`
598
+ const { stdout } = await execAsync(cmd, { timeout: 5000 })
599
+ if (stdout.trim() === 'PONG') {
600
+ logDebug(`Valkey ready on port ${port}`)
601
+ return true
602
+ }
603
+ } catch {
604
+ await new Promise((resolve) => setTimeout(resolve, checkInterval))
605
+ }
606
+ }
607
+
608
+ logWarning(`Valkey did not become ready within ${timeoutMs}ms`)
609
+ return false
610
+ }
611
+
612
+ /**
613
+ * Stop Valkey server
614
+ * Uses SHUTDOWN SAVE via valkey-cli to persist data before stopping
615
+ */
616
+ async stop(container: ContainerConfig): Promise<void> {
617
+ const { name, port, version } = container
618
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
619
+ const pidFile = join(containerDir, 'valkey.pid')
620
+
621
+ logDebug(`Stopping Valkey container "${name}" on port ${port}`)
622
+
623
+ // Try graceful shutdown via valkey-cli
624
+ const valkeyCli = await this.getValkeyCliPathForVersion(version)
625
+ if (valkeyCli) {
626
+ try {
627
+ const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} SHUTDOWN SAVE`
628
+ await execAsync(cmd, { timeout: 10000 })
629
+ logDebug('Valkey shutdown command sent')
630
+ // Wait a bit for process to exit
631
+ await new Promise((resolve) => setTimeout(resolve, 2000))
632
+ } catch (error) {
633
+ logDebug(`valkey-cli shutdown failed: ${error}`)
634
+ // Continue to PID-based shutdown
635
+ }
636
+ }
637
+
638
+ // Get PID and force kill if needed
639
+ let pid: number | null = null
640
+
641
+ if (existsSync(pidFile)) {
642
+ try {
643
+ const content = await readFile(pidFile, 'utf8')
644
+ pid = parseInt(content.trim(), 10)
645
+ } catch {
646
+ // Ignore
647
+ }
648
+ }
649
+
650
+ // Kill process if still running
651
+ if (pid && platformService.isProcessRunning(pid)) {
652
+ logDebug(`Killing Valkey process ${pid}`)
653
+ try {
654
+ await platformService.terminateProcess(pid, false)
655
+ await new Promise((resolve) => setTimeout(resolve, 2000))
656
+
657
+ if (platformService.isProcessRunning(pid)) {
658
+ logWarning(`Graceful termination failed, force killing ${pid}`)
659
+ await platformService.terminateProcess(pid, true)
660
+ }
661
+ } catch (error) {
662
+ logDebug(`Process termination error: ${error}`)
663
+ }
664
+ }
665
+
666
+ // Cleanup PID file
667
+ if (existsSync(pidFile)) {
668
+ try {
669
+ await unlink(pidFile)
670
+ } catch {
671
+ // Ignore
672
+ }
673
+ }
674
+
675
+ logDebug('Valkey stopped')
676
+ }
677
+
678
+ // Get Valkey server status
679
+ async status(container: ContainerConfig): Promise<StatusResult> {
680
+ const { name, port, version } = container
681
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
682
+ const pidFile = join(containerDir, 'valkey.pid')
683
+
684
+ // Try pinging with valkey-cli
685
+ const valkeyCli = await this.getValkeyCliPathForVersion(version)
686
+ if (valkeyCli) {
687
+ try {
688
+ const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} PING`
689
+ const { stdout } = await execAsync(cmd, { timeout: 5000 })
690
+ if (stdout.trim() === 'PONG') {
691
+ return { running: true, message: 'Valkey is running' }
692
+ }
693
+ } catch {
694
+ // Not responding, check PID
695
+ }
696
+ }
697
+
698
+ // Check PID file
699
+ if (existsSync(pidFile)) {
700
+ try {
701
+ const content = await readFile(pidFile, 'utf8')
702
+ const pid = parseInt(content.trim(), 10)
703
+ if (!isNaN(pid) && pid > 0 && platformService.isProcessRunning(pid)) {
704
+ return {
705
+ running: true,
706
+ message: `Valkey is running (PID: ${pid})`,
707
+ }
708
+ }
709
+ } catch {
710
+ // Ignore
711
+ }
712
+ }
713
+
714
+ return { running: false, message: 'Valkey is not running' }
715
+ }
716
+
717
+ // Detect backup format
718
+ async detectBackupFormat(filePath: string): Promise<BackupFormat> {
719
+ return detectBackupFormatImpl(filePath)
720
+ }
721
+
722
+ /**
723
+ * Restore a backup
724
+ * IMPORTANT: Valkey must be stopped before restore
725
+ */
726
+ async restore(
727
+ container: ContainerConfig,
728
+ backupPath: string,
729
+ options: { database?: string; flush?: boolean } = {},
730
+ ): Promise<RestoreResult> {
731
+ const { name, port } = container
732
+ const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
733
+
734
+ return restoreBackup(backupPath, {
735
+ containerName: name,
736
+ dataDir,
737
+ port,
738
+ database: options.database || container.database || '0',
739
+ flush: options.flush,
740
+ })
741
+ }
742
+
743
+ /**
744
+ * Get connection string
745
+ * Format: redis://127.0.0.1:PORT/DATABASE
746
+ * (Uses redis:// scheme for client compatibility)
747
+ */
748
+ getConnectionString(container: ContainerConfig, database?: string): string {
749
+ const { port } = container
750
+ const db = database || container.database || '0'
751
+ return `redis://127.0.0.1:${port}/${db}`
752
+ }
753
+
754
+ /**
755
+ * Get path to valkey-cli for a specific version
756
+ * @param version - Optional version (e.g., "8", "9"). If not provided, uses cached path.
757
+ * @deprecated Use getValkeyCliPath() instead
758
+ */
759
+ async getValkeyCliPathForVersion(version?: string): Promise<string> {
760
+ return this.getValkeyCliPath(version)
761
+ }
762
+
763
+ // Open valkey-cli interactive shell
764
+ async connect(container: ContainerConfig, database?: string): Promise<void> {
765
+ const { port, version } = container
766
+ const db = database || container.database || '0'
767
+
768
+ const valkeyCli = await this.getValkeyCliPathForVersion(version)
769
+
770
+ const spawnOptions: SpawnOptions = {
771
+ stdio: 'inherit',
772
+ }
773
+
774
+ return new Promise((resolve, reject) => {
775
+ const proc = spawn(
776
+ valkeyCli,
777
+ ['-h', '127.0.0.1', '-p', String(port), '-n', db],
778
+ spawnOptions,
779
+ )
780
+
781
+ proc.on('error', reject)
782
+ proc.on('close', () => resolve())
783
+ })
784
+ }
785
+
786
+ // Get path to iredis (enhanced CLI) if installed
787
+ // Note: iredis works with Valkey since it's protocol-compatible
788
+ private async getIredisPath(): Promise<string | null> {
789
+ // Check config cache first
790
+ const cached = await configManager.getBinaryPath('iredis')
791
+ if (cached && existsSync(cached)) {
792
+ return cached
793
+ }
794
+
795
+ // Check system PATH
796
+ const systemPath = await platformService.findToolPath('iredis')
797
+ if (systemPath) {
798
+ return systemPath
799
+ }
800
+
801
+ return null
802
+ }
803
+
804
+ // Connect with iredis (enhanced CLI)
805
+ async connectWithIredis(
806
+ container: ContainerConfig,
807
+ database?: string,
808
+ ): Promise<void> {
809
+ const { port } = container
810
+ const db = database || container.database || '0'
811
+
812
+ const iredis = await this.getIredisPath()
813
+ if (!iredis) {
814
+ throw new Error(
815
+ 'iredis not found. Install it with:\n' +
816
+ ' macOS: brew install iredis\n' +
817
+ ' pip: pip install iredis',
818
+ )
819
+ }
820
+
821
+ const spawnOptions: SpawnOptions = {
822
+ stdio: 'inherit',
823
+ }
824
+
825
+ return new Promise((resolve, reject) => {
826
+ const proc = spawn(
827
+ iredis,
828
+ ['-h', '127.0.0.1', '-p', String(port), '-n', db],
829
+ spawnOptions,
830
+ )
831
+
832
+ proc.on('error', reject)
833
+ proc.on('close', () => resolve())
834
+ })
835
+ }
836
+
837
+ /**
838
+ * Create a new database
839
+ * Valkey uses numbered databases (0-15), they always exist
840
+ * This is effectively a no-op
841
+ */
842
+ async createDatabase(
843
+ _container: ContainerConfig,
844
+ database: string,
845
+ ): Promise<void> {
846
+ const dbNum = parseInt(database, 10)
847
+ if (isNaN(dbNum) || dbNum < 0 || dbNum > 15) {
848
+ throw new Error(
849
+ `Invalid Valkey database number: ${database}. Must be 0-15.`,
850
+ )
851
+ }
852
+ // No-op - Valkey databases always exist
853
+ logDebug(
854
+ `Valkey database ${database} is available (databases 0-15 always exist)`,
855
+ )
856
+ }
857
+
858
+ /**
859
+ * Drop a database
860
+ * Uses FLUSHDB to clear all keys in the specified database
861
+ */
862
+ async dropDatabase(
863
+ container: ContainerConfig,
864
+ database: string,
865
+ ): Promise<void> {
866
+ const { port, version } = container
867
+ const dbNum = parseInt(database, 10)
868
+ if (isNaN(dbNum) || dbNum < 0 || dbNum > 15) {
869
+ throw new Error(
870
+ `Invalid Valkey database number: ${database}. Must be 0-15.`,
871
+ )
872
+ }
873
+
874
+ const valkeyCli = await this.getValkeyCliPathForVersion(version)
875
+
876
+ // SELECT the database and FLUSHDB
877
+ const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} -n ${database} FLUSHDB`
878
+
879
+ try {
880
+ await execAsync(cmd, { timeout: 10000 })
881
+ logDebug(`Flushed Valkey database ${database}`)
882
+ } catch (error) {
883
+ const err = error as Error
884
+ logDebug(`FLUSHDB failed: ${err.message}`)
885
+ throw new Error(
886
+ `Failed to flush Valkey database ${database}: ${err.message}`,
887
+ )
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Get the memory usage of the Valkey server in bytes
893
+ *
894
+ * NOTE: Valkey does not provide per-database memory statistics.
895
+ * This returns the total server memory (used_memory from INFO memory),
896
+ * not the size of a specific numbered database (0-15).
897
+ * This is acceptable for SpinDB since each container runs one Valkey server.
898
+ */
899
+ async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
900
+ const { port, version } = container
901
+
902
+ try {
903
+ const valkeyCli = await this.getValkeyCliPathForVersion(version)
904
+ // INFO memory returns server-wide stats (database selection has no effect)
905
+ const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} INFO memory`
906
+
907
+ const { stdout } = await execAsync(cmd, { timeout: 10000 })
908
+
909
+ // Parse used_memory (total server memory) from INFO output
910
+ const match = stdout.match(/used_memory:(\d+)/)
911
+ if (match) {
912
+ return parseInt(match[1], 10)
913
+ }
914
+ return null
915
+ } catch {
916
+ return null
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Dump from a remote Valkey connection
922
+ * Valkey doesn't support remote dump like pg_dump/mongodump
923
+ * Throw an error with guidance
924
+ */
925
+ async dumpFromConnectionString(
926
+ _connectionString: string,
927
+ _outputPath: string,
928
+ ): Promise<DumpResult> {
929
+ throw new Error(
930
+ 'Valkey does not support creating containers from remote connection strings.\n' +
931
+ 'To migrate data from a remote Valkey instance:\n' +
932
+ ' 1. On remote server: valkey-cli --rdb dump.rdb\n' +
933
+ ' 2. Copy dump.rdb to local machine\n' +
934
+ ' 3. spindb restore <container> dump.rdb',
935
+ )
936
+ }
937
+
938
+ // Create a backup
939
+ async backup(
940
+ container: ContainerConfig,
941
+ outputPath: string,
942
+ options: BackupOptions,
943
+ ): Promise<BackupResult> {
944
+ return createBackup(container, outputPath, options)
945
+ }
946
+
947
+ // Run a Valkey command file or inline command
948
+ async runScript(
949
+ container: ContainerConfig,
950
+ options: { file?: string; sql?: string; database?: string },
951
+ ): Promise<void> {
952
+ const { port, version } = container
953
+ const db = options.database || container.database || '0'
954
+
955
+ const valkeyCli = await this.getValkeyCliPathForVersion(version)
956
+
957
+ if (options.file) {
958
+ // Read file and pipe to valkey-cli via stdin (avoids shell interpolation issues)
959
+ const fileContent = await readFile(options.file, 'utf-8')
960
+ const args = ['-h', '127.0.0.1', '-p', String(port), '-n', db]
961
+
962
+ await new Promise<void>((resolve, reject) => {
963
+ const proc = spawn(valkeyCli, args, {
964
+ stdio: ['pipe', 'inherit', 'inherit'],
965
+ })
966
+
967
+ let rejected = false
968
+
969
+ proc.on('error', (err) => {
970
+ rejected = true
971
+ reject(err)
972
+ })
973
+
974
+ proc.on('close', (code) => {
975
+ if (rejected) return
976
+ if (code === 0 || code === null) {
977
+ resolve()
978
+ } else {
979
+ reject(new Error(`valkey-cli exited with code ${code}`))
980
+ }
981
+ })
982
+
983
+ // Write file content to stdin and close it
984
+ proc.stdin?.write(fileContent)
985
+ proc.stdin?.end()
986
+ })
987
+ } else if (options.sql) {
988
+ // Run inline command by piping to valkey-cli stdin (avoids shell quoting issues on Windows)
989
+ const args = ['-h', '127.0.0.1', '-p', String(port), '-n', db]
990
+
991
+ await new Promise<void>((resolve, reject) => {
992
+ const proc = spawn(valkeyCli, args, {
993
+ stdio: ['pipe', 'inherit', 'inherit'],
994
+ })
995
+
996
+ let rejected = false
997
+
998
+ proc.on('error', (err) => {
999
+ rejected = true
1000
+ reject(err)
1001
+ })
1002
+
1003
+ proc.on('close', (code) => {
1004
+ if (rejected) return
1005
+ if (code === 0 || code === null) {
1006
+ resolve()
1007
+ } else {
1008
+ reject(new Error(`valkey-cli exited with code ${code}`))
1009
+ }
1010
+ })
1011
+
1012
+ // Write command to stdin and close it
1013
+ proc.stdin?.write(options.sql + '\n')
1014
+ proc.stdin?.end()
1015
+ })
1016
+ } else {
1017
+ throw new Error('Either file or sql option must be provided')
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ export const valkeyEngine = new ValkeyEngine()