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.
@@ -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.on('data', (data: Buffer) => {
279
+ proc.stdout?.on('data', (data: Buffer) => {
194
280
  stdout += data.toString()
195
281
  })
196
- proc.stderr.on('data', (data: Buffer) => {
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 args = [
229
- '--initialize-insecure',
230
- `--datadir=${dataDir}`,
231
- `--user=${process.env.USER || 'mysql'}`,
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.on('data', (data: Buffer) => {
350
+ proc.stdout?.on('data', (data: Buffer) => {
243
351
  stdout += data.toString()
244
352
  })
245
- proc.stderr.on('data', (data: Buffer) => {
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 socketFile = join(
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
- return new Promise((resolve, reject) => {
310
- const proc = spawn(mysqld, args, {
311
- stdio: ['ignore', 'ignore', 'ignore'],
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
- try {
321
- await writeFile(pidFile, String(proc.pid))
322
- } catch (error) {
323
- // PID file might be written by mysqld itself
324
- logDebug(`Could not write PID file (mysqld may write it): ${error}`)
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
- proc.on('error', reject)
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
- try {
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
- } catch {
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 SIGTERM
489
- logDebug('No mysqladmin available, sending SIGTERM')
628
+ // No mysqladmin available, send graceful termination signal
629
+ logDebug('No mysqladmin available, sending termination signal')
490
630
  try {
491
- process.kill(pid, 'SIGTERM')
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
- try {
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 (SIGTERM -> SIGKILL)
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 SIGTERM first (if not already sent in graceful shutdown)
670
+ // Try graceful termination first (if not already sent in graceful shutdown)
532
671
  try {
533
- process.kill(pid, 'SIGTERM')
672
+ await platformService.terminateProcess(pid, false)
534
673
  await this.sleep(2000)
535
674
 
536
675
  // Check if still running
537
- try {
538
- process.kill(pid, 0)
539
- } catch {
676
+ if (!platformService.isProcessRunning(pid)) {
540
677
  // Process terminated
541
- logDebug(`Process ${pid} terminated after SIGTERM`)
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(`SIGTERM failed: ${e.message}`)
689
+ logDebug(`Graceful termination failed: ${e.message}`)
553
690
  }
554
691
 
555
- // Escalate to SIGKILL
556
- logWarning(`SIGTERM failed, escalating to SIGKILL for process ${pid}`)
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
- process.kill(pid, 'SIGKILL')
699
+ await platformService.terminateProcess(pid, true)
559
700
  await this.sleep(1000)
560
701
 
561
702
  // Verify process is gone
562
- try {
563
- process.kill(pid, 0)
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 SIGKILL`,
707
+ `Failed to stop MySQL process ${pid} even with force kill`,
568
708
  'error',
569
- `Try manually killing the process: kill -9 ${pid}`,
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(`SIGKILL failed: ${e.message}`)
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
- process.kill(pid, 0) // Check if process exists
639
- return { running: true, message: `MySQL is running (PID: ${pid})` }
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
- if (!mysql) {
700
- throw new Error(
701
- 'mysql client not found. Install MySQL client tools:\n' +
702
- ' macOS: brew install mysql-client\n' +
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
- { stdio: 'inherit' },
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 'CREATE DATABASE `{db}`'
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
- // Use backticks for MySQL database names
741
- await execAsync(
742
- `"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'CREATE DATABASE IF NOT EXISTS \`${database}\`'`,
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 'DROP DATABASE IF EXISTS `{db}`'
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
- await execAsync(
771
- `"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'DROP DATABASE IF EXISTS \`${database}\`'`,
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
- if (!mysql) {
905
- throw new Error(
906
- 'mysql client not found. Install MySQL client tools:\n' +
907
- ' macOS: brew install mysql-client\n' +
908
- ' Ubuntu/Debian: sudo apt install mysql-client',
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, { stdio: 'inherit' })
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()