spindb 0.9.3 → 0.10.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.
- package/README.md +19 -10
- package/cli/commands/create.ts +72 -42
- package/cli/commands/engines.ts +61 -0
- package/cli/commands/logs.ts +3 -29
- package/cli/commands/menu/container-handlers.ts +32 -3
- package/cli/commands/menu/sql-handlers.ts +4 -26
- package/cli/helpers.ts +6 -6
- package/cli/index.ts +3 -3
- package/cli/utils/file-follower.ts +95 -0
- package/config/defaults.ts +3 -0
- package/config/os-dependencies.ts +79 -1
- package/core/binary-manager.ts +181 -66
- package/core/config-manager.ts +5 -65
- package/core/dependency-manager.ts +39 -1
- package/core/platform-service.ts +149 -11
- package/core/process-manager.ts +152 -33
- package/engines/base-engine.ts +27 -0
- package/engines/mysql/backup.ts +12 -5
- package/engines/mysql/index.ts +328 -110
- package/engines/mysql/restore.ts +22 -6
- package/engines/postgresql/backup.ts +7 -3
- package/engines/postgresql/binary-manager.ts +47 -31
- package/engines/postgresql/edb-binary-urls.ts +123 -0
- package/engines/postgresql/index.ts +109 -22
- package/engines/postgresql/version-maps.ts +63 -0
- package/engines/sqlite/index.ts +9 -19
- package/package.json +4 -2
package/engines/mysql/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Manages MySQL database containers using system-installed MySQL binaries
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { spawn, exec } from 'child_process'
|
|
6
|
+
import { spawn, exec, type SpawnOptions } from 'child_process'
|
|
7
7
|
import { promisify } from 'util'
|
|
8
8
|
import { existsSync, createReadStream } from 'fs'
|
|
9
9
|
import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises'
|
|
@@ -11,6 +11,12 @@ import { join } from 'path'
|
|
|
11
11
|
import { BaseEngine } from '../base-engine'
|
|
12
12
|
import { paths } from '../../config/paths'
|
|
13
13
|
import { getEngineDefaults } from '../../config/defaults'
|
|
14
|
+
import {
|
|
15
|
+
platformService,
|
|
16
|
+
isWindows,
|
|
17
|
+
getWindowsSpawnOptions,
|
|
18
|
+
} from '../../core/platform-service'
|
|
19
|
+
import { configManager } from '../../core/config-manager'
|
|
14
20
|
import {
|
|
15
21
|
logDebug,
|
|
16
22
|
logWarning,
|
|
@@ -20,8 +26,8 @@ import {
|
|
|
20
26
|
} from '../../core/error-handler'
|
|
21
27
|
import {
|
|
22
28
|
getMysqldPath,
|
|
23
|
-
getMysqlClientPath,
|
|
24
|
-
getMysqladminPath,
|
|
29
|
+
getMysqlClientPath as findMysqlClientPath,
|
|
30
|
+
getMysqladminPath as findMysqladminPath,
|
|
25
31
|
getMysqldumpPath,
|
|
26
32
|
getMysqlInstallDbPath,
|
|
27
33
|
getMariadbInstallDbPath,
|
|
@@ -52,6 +58,61 @@ export * from './restore'
|
|
|
52
58
|
|
|
53
59
|
const execAsync = promisify(exec)
|
|
54
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Build a Windows-safe mysql command string for either a file or inline SQL.
|
|
63
|
+
* This is exported for unit testing.
|
|
64
|
+
*/
|
|
65
|
+
export function buildWindowsMysqlCommand(
|
|
66
|
+
mysqlPath: string,
|
|
67
|
+
port: number,
|
|
68
|
+
user: string,
|
|
69
|
+
db: string,
|
|
70
|
+
options: { file?: string; sql?: string },
|
|
71
|
+
): string {
|
|
72
|
+
if (!options.file && !options.sql) {
|
|
73
|
+
throw new Error('Either file or sql option must be provided')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let cmd = `"${mysqlPath}" -h 127.0.0.1 -P ${port} -u ${user} ${db}`
|
|
77
|
+
|
|
78
|
+
if (options.file) {
|
|
79
|
+
// Redirection requires shell, so use < operator
|
|
80
|
+
cmd += ` < "${options.file}"`
|
|
81
|
+
} else if (options.sql) {
|
|
82
|
+
const escaped = options.sql.replace(/"/g, '\\"')
|
|
83
|
+
cmd += ` -e "${escaped}"`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return cmd
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a platform-safe mysql command string with SQL inline.
|
|
91
|
+
* On Unix, uses single quotes to prevent shell interpretation of backticks.
|
|
92
|
+
* On Windows, uses double quotes (backticks are literal in cmd.exe).
|
|
93
|
+
* This is exported for unit testing.
|
|
94
|
+
*/
|
|
95
|
+
export function buildMysqlInlineCommand(
|
|
96
|
+
mysqlPath: string,
|
|
97
|
+
port: number,
|
|
98
|
+
user: string,
|
|
99
|
+
sql: string,
|
|
100
|
+
options: { database?: string } = {},
|
|
101
|
+
): string {
|
|
102
|
+
const dbArg = options.database ? ` ${options.database}` : ''
|
|
103
|
+
|
|
104
|
+
if (isWindows()) {
|
|
105
|
+
// Windows: use double quotes, escape inner double quotes
|
|
106
|
+
const escaped = sql.replace(/"/g, '\\"')
|
|
107
|
+
return `"${mysqlPath}" -h 127.0.0.1 -P ${port} -u ${user}${dbArg} -e "${escaped}"`
|
|
108
|
+
} else {
|
|
109
|
+
// Unix: use single quotes to prevent backtick interpretation
|
|
110
|
+
// Escape any single quotes in the SQL by ending the string, adding escaped quote, starting new string
|
|
111
|
+
const escaped = sql.replace(/'/g, "'\\''")
|
|
112
|
+
return `"${mysqlPath}" -h 127.0.0.1 -P ${port} -u ${user}${dbArg} -e '${escaped}'`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
55
116
|
const ENGINE = 'mysql'
|
|
56
117
|
const engineDef = getEngineDefaults(ENGINE)
|
|
57
118
|
|
|
@@ -176,11 +237,36 @@ export class MySQLEngine extends BaseEngine {
|
|
|
176
237
|
|
|
177
238
|
// MariaDB initialization
|
|
178
239
|
// --auth-root-authentication-method=normal allows passwordless root login via socket
|
|
240
|
+
const { platform } = platformService.getPlatformInfo()
|
|
241
|
+
|
|
242
|
+
if (isWindows()) {
|
|
243
|
+
// On Windows, use exec with properly quoted command
|
|
244
|
+
const cmd = `"${installDb}" --datadir="${dataDir}" --auth-root-authentication-method=normal`
|
|
245
|
+
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
exec(cmd, { timeout: 120000 }, async (error, stdout, stderr) => {
|
|
248
|
+
if (error) {
|
|
249
|
+
await cleanupOnFailure()
|
|
250
|
+
reject(
|
|
251
|
+
new Error(
|
|
252
|
+
`MariaDB initialization failed with code ${error.code}: ${stderr || stdout || error.message}`,
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
} else {
|
|
256
|
+
resolve(dataDir)
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Unix path - use spawn without shell
|
|
179
263
|
const args = [
|
|
180
264
|
`--datadir=${dataDir}`,
|
|
181
|
-
`--user=${process.env.USER || 'mysql'}`,
|
|
182
265
|
'--auth-root-authentication-method=normal',
|
|
183
266
|
]
|
|
267
|
+
if (platform !== 'win32') {
|
|
268
|
+
args.push(`--user=${process.env.USER || 'mysql'}`)
|
|
269
|
+
}
|
|
184
270
|
|
|
185
271
|
return new Promise((resolve, reject) => {
|
|
186
272
|
const proc = spawn(installDb, args, {
|
|
@@ -190,10 +276,10 @@ export class MySQLEngine extends BaseEngine {
|
|
|
190
276
|
let stdout = ''
|
|
191
277
|
let stderr = ''
|
|
192
278
|
|
|
193
|
-
proc.stdout
|
|
279
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
194
280
|
stdout += data.toString()
|
|
195
281
|
})
|
|
196
|
-
proc.stderr
|
|
282
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
197
283
|
stderr += data.toString()
|
|
198
284
|
})
|
|
199
285
|
|
|
@@ -225,11 +311,33 @@ export class MySQLEngine extends BaseEngine {
|
|
|
225
311
|
|
|
226
312
|
// MySQL initialization
|
|
227
313
|
// --initialize-insecure creates root user without password (for local dev)
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
314
|
+
const { platform } = platformService.getPlatformInfo()
|
|
315
|
+
|
|
316
|
+
if (isWindows()) {
|
|
317
|
+
// On Windows, use exec with properly quoted command
|
|
318
|
+
const cmd = `"${mysqld}" --initialize-insecure --datadir="${dataDir}"`
|
|
319
|
+
|
|
320
|
+
return new Promise((resolve, reject) => {
|
|
321
|
+
exec(cmd, { timeout: 120000 }, async (error, stdout, stderr) => {
|
|
322
|
+
if (error) {
|
|
323
|
+
await cleanupOnFailure()
|
|
324
|
+
reject(
|
|
325
|
+
new Error(
|
|
326
|
+
`MySQL initialization failed with code ${error.code}: ${stderr || stdout || error.message}`,
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
} else {
|
|
330
|
+
resolve(dataDir)
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Unix path - use spawn without shell
|
|
337
|
+
const args = ['--initialize-insecure', `--datadir=${dataDir}`]
|
|
338
|
+
if (platform !== 'win32') {
|
|
339
|
+
args.push(`--user=${process.env.USER || 'mysql'}`)
|
|
340
|
+
}
|
|
233
341
|
|
|
234
342
|
return new Promise((resolve, reject) => {
|
|
235
343
|
const proc = spawn(mysqld, args, {
|
|
@@ -239,10 +347,10 @@ export class MySQLEngine extends BaseEngine {
|
|
|
239
347
|
let stdout = ''
|
|
240
348
|
let stderr = ''
|
|
241
349
|
|
|
242
|
-
proc.stdout
|
|
350
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
243
351
|
stdout += data.toString()
|
|
244
352
|
})
|
|
245
|
-
proc.stderr
|
|
353
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
246
354
|
stderr += data.toString()
|
|
247
355
|
})
|
|
248
356
|
|
|
@@ -285,10 +393,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
285
393
|
const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
|
|
286
394
|
const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
|
|
287
395
|
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
288
|
-
const
|
|
289
|
-
paths.getContainerPath(name, { engine: ENGINE }),
|
|
290
|
-
'mysql.sock',
|
|
291
|
-
)
|
|
396
|
+
const { platform } = platformService.getPlatformInfo()
|
|
292
397
|
|
|
293
398
|
onProgress?.({ stage: 'starting', message: 'Starting MySQL...' })
|
|
294
399
|
|
|
@@ -299,29 +404,62 @@ export class MySQLEngine extends BaseEngine {
|
|
|
299
404
|
const args = [
|
|
300
405
|
`--datadir=${dataDir}`,
|
|
301
406
|
`--port=${port}`,
|
|
302
|
-
`--socket=${socketFile}`,
|
|
303
407
|
`--pid-file=${pidFile}`,
|
|
304
408
|
`--log-error=${logFile}`,
|
|
305
409
|
'--bind-address=127.0.0.1',
|
|
306
410
|
`--max-connections=${engineDef.maxConnections}`, // Higher than default 151 for parallel builds
|
|
307
411
|
]
|
|
308
412
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
413
|
+
// Unix sockets are not available on Windows - use TCP only
|
|
414
|
+
if (platform !== 'win32') {
|
|
415
|
+
const socketFile = join(
|
|
416
|
+
paths.getContainerPath(name, { engine: ENGINE }),
|
|
417
|
+
'mysql.sock',
|
|
418
|
+
)
|
|
419
|
+
args.push(`--socket=${socketFile}`)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// On both Windows and Unix, use spawn with detached: true
|
|
423
|
+
// Windows also uses windowsHide: true to prevent console window
|
|
424
|
+
let proc: ReturnType<typeof spawn> | null = null
|
|
425
|
+
|
|
426
|
+
if (isWindows()) {
|
|
427
|
+
// Spawn mysqld detached on Windows; capture stdout/stderr briefly
|
|
428
|
+
// to surface startup errors in logs.
|
|
429
|
+
proc = spawn(mysqld, args, {
|
|
430
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
312
431
|
detached: true,
|
|
432
|
+
windowsHide: true,
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
436
|
+
logDebug(`mysqld stdout: ${data.toString()}`)
|
|
437
|
+
})
|
|
438
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
439
|
+
logDebug(`mysqld stderr: ${data.toString()}`)
|
|
313
440
|
})
|
|
314
441
|
|
|
315
442
|
proc.unref()
|
|
443
|
+
} else {
|
|
444
|
+
proc = spawn(mysqld, args, {
|
|
445
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
446
|
+
detached: true,
|
|
447
|
+
})
|
|
448
|
+
proc.unref()
|
|
449
|
+
}
|
|
316
450
|
|
|
451
|
+
return new Promise((resolve, reject) => {
|
|
317
452
|
// Give MySQL a moment to start
|
|
318
453
|
setTimeout(async () => {
|
|
319
|
-
// Write PID file manually since we're running detached
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
454
|
+
// Write PID file manually on Unix since we're running detached
|
|
455
|
+
// On Windows, MySQL writes its own PID file
|
|
456
|
+
if (proc && proc.pid) {
|
|
457
|
+
try {
|
|
458
|
+
await writeFile(pidFile, String(proc.pid))
|
|
459
|
+
} catch (error) {
|
|
460
|
+
// PID file might be written by mysqld itself
|
|
461
|
+
logDebug(`Could not write PID file (mysqld may write it): ${error}`)
|
|
462
|
+
}
|
|
325
463
|
}
|
|
326
464
|
|
|
327
465
|
// Wait for MySQL to be ready
|
|
@@ -332,7 +470,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
332
470
|
const checkReady = async () => {
|
|
333
471
|
attempts++
|
|
334
472
|
try {
|
|
335
|
-
const mysqladmin = await getMysqladminPath()
|
|
473
|
+
const mysqladmin = await this.getMysqladminPath()
|
|
336
474
|
if (mysqladmin) {
|
|
337
475
|
await execAsync(
|
|
338
476
|
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
|
|
@@ -363,7 +501,10 @@ export class MySQLEngine extends BaseEngine {
|
|
|
363
501
|
checkReady()
|
|
364
502
|
}, 1000)
|
|
365
503
|
|
|
366
|
-
|
|
504
|
+
// Only attach error handler on Unix where we have a proc object
|
|
505
|
+
if (proc) {
|
|
506
|
+
proc.on('error', reject)
|
|
507
|
+
}
|
|
367
508
|
})
|
|
368
509
|
}
|
|
369
510
|
|
|
@@ -382,7 +523,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
382
523
|
if (pid === null) {
|
|
383
524
|
// No valid PID file - check if process might still be running on port
|
|
384
525
|
logDebug('No valid PID, checking if MySQL is responding on port')
|
|
385
|
-
const mysqladmin = await getMysqladminPath()
|
|
526
|
+
const mysqladmin = await this.getMysqladminPath()
|
|
386
527
|
if (mysqladmin) {
|
|
387
528
|
try {
|
|
388
529
|
await execAsync(
|
|
@@ -437,11 +578,10 @@ export class MySQLEngine extends BaseEngine {
|
|
|
437
578
|
}
|
|
438
579
|
|
|
439
580
|
// Verify process exists
|
|
440
|
-
|
|
441
|
-
process.kill(pid, 0) // Signal 0 = check existence
|
|
581
|
+
if (platformService.isProcessRunning(pid)) {
|
|
442
582
|
logDebug(`Validated PID ${pid}`)
|
|
443
583
|
return pid
|
|
444
|
-
}
|
|
584
|
+
} else {
|
|
445
585
|
logWarning(`PID file references non-existent process ${pid}`, {
|
|
446
586
|
code: ErrorCodes.PID_FILE_STALE,
|
|
447
587
|
pidFile,
|
|
@@ -470,7 +610,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
470
610
|
pid?: number,
|
|
471
611
|
timeoutMs = 10000,
|
|
472
612
|
): Promise<boolean> {
|
|
473
|
-
const mysqladmin = await getMysqladminPath()
|
|
613
|
+
const mysqladmin = await this.getMysqladminPath()
|
|
474
614
|
|
|
475
615
|
if (mysqladmin) {
|
|
476
616
|
try {
|
|
@@ -485,10 +625,10 @@ export class MySQLEngine extends BaseEngine {
|
|
|
485
625
|
// Continue to wait for process to die or send SIGTERM
|
|
486
626
|
}
|
|
487
627
|
} else if (pid) {
|
|
488
|
-
// No mysqladmin available, send
|
|
489
|
-
logDebug('No mysqladmin available, sending
|
|
628
|
+
// No mysqladmin available, send graceful termination signal
|
|
629
|
+
logDebug('No mysqladmin available, sending termination signal')
|
|
490
630
|
try {
|
|
491
|
-
|
|
631
|
+
await platformService.terminateProcess(pid, false)
|
|
492
632
|
} catch {
|
|
493
633
|
// Process may already be dead
|
|
494
634
|
return true
|
|
@@ -501,14 +641,12 @@ export class MySQLEngine extends BaseEngine {
|
|
|
501
641
|
const checkIntervalMs = 200
|
|
502
642
|
|
|
503
643
|
while (Date.now() - startTime < timeoutMs) {
|
|
504
|
-
|
|
505
|
-
process.kill(pid, 0)
|
|
506
|
-
await this.sleep(checkIntervalMs)
|
|
507
|
-
} catch {
|
|
644
|
+
if (!platformService.isProcessRunning(pid)) {
|
|
508
645
|
// Process is gone
|
|
509
646
|
logDebug(`Process ${pid} terminated after graceful shutdown`)
|
|
510
647
|
return true
|
|
511
648
|
}
|
|
649
|
+
await this.sleep(checkIntervalMs)
|
|
512
650
|
}
|
|
513
651
|
|
|
514
652
|
logDebug(`Graceful shutdown timed out after ${timeoutMs}ms`)
|
|
@@ -520,7 +658,8 @@ export class MySQLEngine extends BaseEngine {
|
|
|
520
658
|
}
|
|
521
659
|
|
|
522
660
|
/**
|
|
523
|
-
* Force kill with signal escalation (
|
|
661
|
+
* Force kill with signal escalation (graceful -> force)
|
|
662
|
+
* Uses platformService for cross-platform process termination
|
|
524
663
|
*/
|
|
525
664
|
private async forceKillWithEscalation(
|
|
526
665
|
pid: number,
|
|
@@ -528,17 +667,15 @@ export class MySQLEngine extends BaseEngine {
|
|
|
528
667
|
): Promise<void> {
|
|
529
668
|
logWarning(`Graceful shutdown failed, force killing process ${pid}`)
|
|
530
669
|
|
|
531
|
-
// Try
|
|
670
|
+
// Try graceful termination first (if not already sent in graceful shutdown)
|
|
532
671
|
try {
|
|
533
|
-
|
|
672
|
+
await platformService.terminateProcess(pid, false)
|
|
534
673
|
await this.sleep(2000)
|
|
535
674
|
|
|
536
675
|
// Check if still running
|
|
537
|
-
|
|
538
|
-
process.kill(pid, 0)
|
|
539
|
-
} catch {
|
|
676
|
+
if (!platformService.isProcessRunning(pid)) {
|
|
540
677
|
// Process terminated
|
|
541
|
-
logDebug(`Process ${pid} terminated after
|
|
678
|
+
logDebug(`Process ${pid} terminated after graceful signal`)
|
|
542
679
|
await this.cleanupPidFile(pidFile)
|
|
543
680
|
return
|
|
544
681
|
}
|
|
@@ -549,32 +686,31 @@ export class MySQLEngine extends BaseEngine {
|
|
|
549
686
|
await this.cleanupPidFile(pidFile)
|
|
550
687
|
return
|
|
551
688
|
}
|
|
552
|
-
logDebug(`
|
|
689
|
+
logDebug(`Graceful termination failed: ${e.message}`)
|
|
553
690
|
}
|
|
554
691
|
|
|
555
|
-
// Escalate to
|
|
556
|
-
|
|
692
|
+
// Escalate to force kill
|
|
693
|
+
const { platform } = platformService.getPlatformInfo()
|
|
694
|
+
const killCmd = platform === 'win32' ? 'taskkill /F' : 'kill -9'
|
|
695
|
+
logWarning(
|
|
696
|
+
`Graceful termination failed, escalating to force kill for process ${pid}`,
|
|
697
|
+
)
|
|
557
698
|
try {
|
|
558
|
-
|
|
699
|
+
await platformService.terminateProcess(pid, true)
|
|
559
700
|
await this.sleep(1000)
|
|
560
701
|
|
|
561
702
|
// Verify process is gone
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
// Process still running after SIGKILL - this is unexpected
|
|
703
|
+
if (platformService.isProcessRunning(pid)) {
|
|
704
|
+
// Process still running after force kill - this is unexpected
|
|
565
705
|
throw new SpinDBError(
|
|
566
706
|
ErrorCodes.PROCESS_STOP_TIMEOUT,
|
|
567
|
-
`Failed to stop MySQL process ${pid} even with
|
|
707
|
+
`Failed to stop MySQL process ${pid} even with force kill`,
|
|
568
708
|
'error',
|
|
569
|
-
`Try manually killing the process:
|
|
709
|
+
`Try manually killing the process: ${killCmd} ${pid}`,
|
|
570
710
|
)
|
|
571
|
-
} catch (checkErr) {
|
|
572
|
-
const checkE = checkErr as NodeJS.ErrnoException
|
|
573
|
-
if (checkE instanceof SpinDBError) throw checkE
|
|
574
|
-
// Process is gone (ESRCH)
|
|
575
|
-
logDebug(`Process ${pid} terminated after SIGKILL`)
|
|
576
|
-
await this.cleanupPidFile(pidFile)
|
|
577
711
|
}
|
|
712
|
+
logDebug(`Process ${pid} terminated after force kill`)
|
|
713
|
+
await this.cleanupPidFile(pidFile)
|
|
578
714
|
} catch (error) {
|
|
579
715
|
if (error instanceof SpinDBError) throw error
|
|
580
716
|
const e = error as NodeJS.ErrnoException
|
|
@@ -583,7 +719,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
583
719
|
await this.cleanupPidFile(pidFile)
|
|
584
720
|
return
|
|
585
721
|
}
|
|
586
|
-
logDebug(`
|
|
722
|
+
logDebug(`Force kill failed: ${e.message}`)
|
|
587
723
|
}
|
|
588
724
|
}
|
|
589
725
|
|
|
@@ -622,7 +758,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
622
758
|
}
|
|
623
759
|
|
|
624
760
|
// Try to ping MySQL
|
|
625
|
-
const mysqladmin = await getMysqladminPath()
|
|
761
|
+
const mysqladmin = await this.getMysqladminPath()
|
|
626
762
|
if (mysqladmin) {
|
|
627
763
|
try {
|
|
628
764
|
await execAsync(`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`)
|
|
@@ -635,8 +771,10 @@ export class MySQLEngine extends BaseEngine {
|
|
|
635
771
|
// Fall back to checking PID
|
|
636
772
|
try {
|
|
637
773
|
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
638
|
-
|
|
639
|
-
|
|
774
|
+
if (platformService.isProcessRunning(pid)) {
|
|
775
|
+
return { running: true, message: `MySQL is running (PID: ${pid})` }
|
|
776
|
+
}
|
|
777
|
+
return { running: false, message: 'MySQL is not running' }
|
|
640
778
|
} catch {
|
|
641
779
|
return { running: false, message: 'MySQL is not running' }
|
|
642
780
|
}
|
|
@@ -687,6 +825,47 @@ export class MySQLEngine extends BaseEngine {
|
|
|
687
825
|
return `mysql://${engineDef.superuser}@127.0.0.1:${port}/${db}`
|
|
688
826
|
}
|
|
689
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Get path to mysql client, using config manager to find it
|
|
830
|
+
*/
|
|
831
|
+
async getMysqlClientPath(): Promise<string> {
|
|
832
|
+
// Prefer explicit config if the user set a path via spindb config
|
|
833
|
+
const configPath = await configManager.getBinaryPath('mysql')
|
|
834
|
+
if (configPath) return configPath
|
|
835
|
+
|
|
836
|
+
// Fallback to platform detection helper
|
|
837
|
+
const detected = await findMysqlClientPath()
|
|
838
|
+
if (!detected) {
|
|
839
|
+
throw new Error(
|
|
840
|
+
'mysql client not found. Install MySQL client tools:\n' +
|
|
841
|
+
' macOS: brew install mysql-client\n' +
|
|
842
|
+
' Ubuntu/Debian: sudo apt install mysql-client\n\n' +
|
|
843
|
+
'Or configure manually: spindb config set mysql /path/to/mysql',
|
|
844
|
+
)
|
|
845
|
+
}
|
|
846
|
+
return detected
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Get path to mysqladmin (used for readiness checks)
|
|
851
|
+
*/
|
|
852
|
+
async getMysqladminPath(): Promise<string> {
|
|
853
|
+
const cfg = await configManager.getBinaryPath('mysqladmin')
|
|
854
|
+
if (cfg) return cfg
|
|
855
|
+
|
|
856
|
+
const detected = await findMysqladminPath()
|
|
857
|
+
if (!detected) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
'mysqladmin not found. Install MySQL client tools:\n' +
|
|
860
|
+
' macOS: brew install mysql-client\n' +
|
|
861
|
+
' Ubuntu/Debian: sudo apt install mysql-client\n\n' +
|
|
862
|
+
'Or configure manually: spindb config set mysqladmin /path/to/mysqladmin',
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return detected
|
|
867
|
+
}
|
|
868
|
+
|
|
690
869
|
/**
|
|
691
870
|
* Open mysql interactive shell
|
|
692
871
|
* Spawn interactive: mysql -h 127.0.0.1 -P {port} -u root {db}
|
|
@@ -695,20 +874,18 @@ export class MySQLEngine extends BaseEngine {
|
|
|
695
874
|
const { port } = container
|
|
696
875
|
const db = database || container.database || 'mysql'
|
|
697
876
|
|
|
698
|
-
const mysql = await getMysqlClientPath()
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
704
|
-
)
|
|
877
|
+
const mysql = await this.getMysqlClientPath()
|
|
878
|
+
|
|
879
|
+
const spawnOptions: SpawnOptions = {
|
|
880
|
+
stdio: 'inherit',
|
|
881
|
+
...getWindowsSpawnOptions(),
|
|
705
882
|
}
|
|
706
883
|
|
|
707
884
|
return new Promise((resolve, reject) => {
|
|
708
885
|
const proc = spawn(
|
|
709
886
|
mysql,
|
|
710
887
|
['-h', '127.0.0.1', '-P', String(port), '-u', engineDef.superuser, db],
|
|
711
|
-
|
|
888
|
+
spawnOptions,
|
|
712
889
|
)
|
|
713
890
|
|
|
714
891
|
proc.on('error', reject)
|
|
@@ -718,7 +895,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
718
895
|
|
|
719
896
|
/**
|
|
720
897
|
* Create a new database
|
|
721
|
-
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e
|
|
898
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e "CREATE DATABASE `{db}`"
|
|
722
899
|
*/
|
|
723
900
|
async createDatabase(
|
|
724
901
|
container: ContainerConfig,
|
|
@@ -727,20 +904,16 @@ export class MySQLEngine extends BaseEngine {
|
|
|
727
904
|
assertValidDatabaseName(database)
|
|
728
905
|
const { port } = container
|
|
729
906
|
|
|
730
|
-
const mysql = await getMysqlClientPath()
|
|
731
|
-
if (!mysql) {
|
|
732
|
-
throw new Error(
|
|
733
|
-
'mysql client not found. Install MySQL client tools:\n' +
|
|
734
|
-
' macOS: brew install mysql-client\n' +
|
|
735
|
-
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
736
|
-
)
|
|
737
|
-
}
|
|
907
|
+
const mysql = await this.getMysqlClientPath()
|
|
738
908
|
|
|
739
909
|
try {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
910
|
+
const cmd = buildMysqlInlineCommand(
|
|
911
|
+
mysql,
|
|
912
|
+
port,
|
|
913
|
+
engineDef.superuser,
|
|
914
|
+
`CREATE DATABASE IF NOT EXISTS \`${database}\``,
|
|
743
915
|
)
|
|
916
|
+
await execAsync(cmd)
|
|
744
917
|
} catch (error) {
|
|
745
918
|
const err = error as Error
|
|
746
919
|
// Ignore "database exists" error
|
|
@@ -752,7 +925,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
752
925
|
|
|
753
926
|
/**
|
|
754
927
|
* Drop a database
|
|
755
|
-
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e
|
|
928
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e "DROP DATABASE IF EXISTS `{db}`"
|
|
756
929
|
*/
|
|
757
930
|
async dropDatabase(
|
|
758
931
|
container: ContainerConfig,
|
|
@@ -761,15 +934,16 @@ export class MySQLEngine extends BaseEngine {
|
|
|
761
934
|
assertValidDatabaseName(database)
|
|
762
935
|
const { port } = container
|
|
763
936
|
|
|
764
|
-
const mysql = await getMysqlClientPath()
|
|
765
|
-
if (!mysql) {
|
|
766
|
-
throw new Error('mysql client not found.')
|
|
767
|
-
}
|
|
937
|
+
const mysql = await this.getMysqlClientPath()
|
|
768
938
|
|
|
769
939
|
try {
|
|
770
|
-
|
|
771
|
-
|
|
940
|
+
const cmd = buildMysqlInlineCommand(
|
|
941
|
+
mysql,
|
|
942
|
+
port,
|
|
943
|
+
engineDef.superuser,
|
|
944
|
+
`DROP DATABASE IF EXISTS \`${database}\``,
|
|
772
945
|
)
|
|
946
|
+
await execAsync(cmd)
|
|
773
947
|
} catch (error) {
|
|
774
948
|
const err = error as Error
|
|
775
949
|
if (!err.message.includes("database doesn't exist")) {
|
|
@@ -791,8 +965,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
791
965
|
assertValidDatabaseName(db)
|
|
792
966
|
|
|
793
967
|
try {
|
|
794
|
-
const mysql = await getMysqlClientPath()
|
|
795
|
-
if (!mysql) return null
|
|
968
|
+
const mysql = await this.getMysqlClientPath()
|
|
796
969
|
|
|
797
970
|
// Query information_schema for total data + index size
|
|
798
971
|
const { stdout } = await execAsync(
|
|
@@ -827,6 +1000,29 @@ export class MySQLEngine extends BaseEngine {
|
|
|
827
1000
|
const { host, port, user, password, database } =
|
|
828
1001
|
parseConnectionString(connectionString)
|
|
829
1002
|
|
|
1003
|
+
// On Windows, build a single command string to avoid spawn + shell quoting issues
|
|
1004
|
+
if (isWindows()) {
|
|
1005
|
+
let cmd = `"${mysqldump}" -h ${host} -P ${port} -u ${user} --result-file "${outputPath}" ${database}`
|
|
1006
|
+
let safeCmd = cmd
|
|
1007
|
+
|
|
1008
|
+
if (password) {
|
|
1009
|
+
cmd = `"${mysqldump}" -h ${host} -P ${port} -u ${user} -p"${password}" --result-file "${outputPath}" ${database}`
|
|
1010
|
+
safeCmd = `"${mysqldump}" -h ${host} -P ${port} -u ${user} -p"****" --result-file "${outputPath}" ${database}`
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
logDebug('Executing mysqldump command', { cmd: safeCmd })
|
|
1014
|
+
await execAsync(cmd)
|
|
1015
|
+
return {
|
|
1016
|
+
filePath: outputPath,
|
|
1017
|
+
stdout: '',
|
|
1018
|
+
stderr: '',
|
|
1019
|
+
code: 0,
|
|
1020
|
+
}
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
throw new Error((error as Error).message)
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
830
1026
|
const args = [
|
|
831
1027
|
'-h',
|
|
832
1028
|
host,
|
|
@@ -844,10 +1040,13 @@ export class MySQLEngine extends BaseEngine {
|
|
|
844
1040
|
|
|
845
1041
|
args.push(database)
|
|
846
1042
|
|
|
1043
|
+
const spawnOptions: SpawnOptions = {
|
|
1044
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1045
|
+
...getWindowsSpawnOptions(),
|
|
1046
|
+
}
|
|
1047
|
+
|
|
847
1048
|
return new Promise((resolve, reject) => {
|
|
848
|
-
const proc = spawn(mysqldump, args,
|
|
849
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
850
|
-
})
|
|
1049
|
+
const proc = spawn(mysqldump, args, spawnOptions)
|
|
851
1050
|
|
|
852
1051
|
let stdout = ''
|
|
853
1052
|
let stderr = ''
|
|
@@ -900,13 +1099,25 @@ export class MySQLEngine extends BaseEngine {
|
|
|
900
1099
|
const db = options.database || container.database || 'mysql'
|
|
901
1100
|
assertValidDatabaseName(db)
|
|
902
1101
|
|
|
903
|
-
const mysql = await getMysqlClientPath()
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1102
|
+
const mysql = await this.getMysqlClientPath()
|
|
1103
|
+
|
|
1104
|
+
// On Windows, build a single command string and use exec to avoid
|
|
1105
|
+
// passing an args array with shell:true which causes quoting issues.
|
|
1106
|
+
if (isWindows()) {
|
|
1107
|
+
const cmd = buildWindowsMysqlCommand(
|
|
1108
|
+
mysql,
|
|
1109
|
+
port,
|
|
1110
|
+
engineDef.superuser,
|
|
1111
|
+
db,
|
|
1112
|
+
options,
|
|
909
1113
|
)
|
|
1114
|
+
try {
|
|
1115
|
+
await execAsync(cmd)
|
|
1116
|
+
return
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
const err = error as Error
|
|
1119
|
+
throw new Error(`mysql failed: ${err.message}`)
|
|
1120
|
+
}
|
|
910
1121
|
}
|
|
911
1122
|
|
|
912
1123
|
const args = [
|
|
@@ -922,8 +1133,13 @@ export class MySQLEngine extends BaseEngine {
|
|
|
922
1133
|
if (options.sql) {
|
|
923
1134
|
// For inline SQL, use -e flag
|
|
924
1135
|
args.push('-e', options.sql)
|
|
1136
|
+
|
|
1137
|
+
const spawnOptions: SpawnOptions = {
|
|
1138
|
+
stdio: 'inherit',
|
|
1139
|
+
}
|
|
1140
|
+
|
|
925
1141
|
return new Promise((resolve, reject) => {
|
|
926
|
-
const proc = spawn(mysql, args,
|
|
1142
|
+
const proc = spawn(mysql, args, spawnOptions)
|
|
927
1143
|
|
|
928
1144
|
proc.on('error', reject)
|
|
929
1145
|
proc.on('close', (code) => {
|
|
@@ -936,13 +1152,15 @@ export class MySQLEngine extends BaseEngine {
|
|
|
936
1152
|
})
|
|
937
1153
|
} else if (options.file) {
|
|
938
1154
|
// For file input, pipe the file to mysql stdin
|
|
1155
|
+
const spawnOptions: SpawnOptions = {
|
|
1156
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
1157
|
+
}
|
|
1158
|
+
|
|
939
1159
|
return new Promise((resolve, reject) => {
|
|
940
1160
|
const fileStream = createReadStream(options.file!)
|
|
941
|
-
const proc = spawn(mysql, args,
|
|
942
|
-
stdio: ['pipe', 'inherit', 'inherit'],
|
|
943
|
-
})
|
|
1161
|
+
const proc = spawn(mysql, args, spawnOptions)
|
|
944
1162
|
|
|
945
|
-
fileStream.pipe(proc.stdin)
|
|
1163
|
+
fileStream.pipe(proc.stdin!)
|
|
946
1164
|
|
|
947
1165
|
fileStream.on('error', (err) => {
|
|
948
1166
|
proc.kill()
|