spindb 0.4.0 → 0.5.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.
- package/README.md +77 -100
- package/cli/commands/clone.ts +3 -1
- package/cli/commands/connect.ts +50 -24
- package/cli/commands/create.ts +265 -112
- package/cli/commands/delete.ts +3 -1
- package/cli/commands/list.ts +14 -3
- package/cli/commands/menu.ts +250 -84
- package/cli/commands/restore.ts +142 -38
- package/cli/commands/start.ts +30 -4
- package/cli/commands/stop.ts +3 -1
- package/cli/ui/prompts.ts +95 -32
- package/config/defaults.ts +40 -15
- package/config/engine-defaults.ts +84 -0
- package/config/os-dependencies.ts +68 -19
- package/config/paths.ts +116 -23
- package/core/binary-manager.ts +30 -5
- package/core/container-manager.ts +124 -60
- package/core/dependency-manager.ts +44 -22
- package/core/port-manager.ts +42 -31
- package/core/postgres-binary-manager.ts +10 -9
- package/core/process-manager.ts +14 -6
- package/engines/index.ts +7 -2
- package/engines/mysql/binary-detection.ts +248 -0
- package/engines/mysql/index.ts +699 -0
- package/engines/postgresql/index.ts +13 -6
- package/package.json +4 -2
- package/types/index.ts +29 -5
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL Engine implementation
|
|
3
|
+
* Manages MySQL database containers using system-installed MySQL binaries
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, exec } from 'child_process'
|
|
7
|
+
import { promisify } from 'util'
|
|
8
|
+
import { existsSync, createReadStream } from 'fs'
|
|
9
|
+
import { mkdir, writeFile, readFile, rm } from 'fs/promises'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { BaseEngine } from '../base-engine'
|
|
12
|
+
import { paths } from '../../config/paths'
|
|
13
|
+
import { getEngineDefaults } from '../../config/defaults'
|
|
14
|
+
import {
|
|
15
|
+
getMysqldPath,
|
|
16
|
+
getMysqlClientPath,
|
|
17
|
+
getMysqladminPath,
|
|
18
|
+
getMysqldumpPath,
|
|
19
|
+
getMysqlInstallDbPath,
|
|
20
|
+
getMariadbInstallDbPath,
|
|
21
|
+
isMariaDB,
|
|
22
|
+
detectInstalledVersions,
|
|
23
|
+
getInstallInstructions,
|
|
24
|
+
} from './binary-detection'
|
|
25
|
+
import type {
|
|
26
|
+
ContainerConfig,
|
|
27
|
+
ProgressCallback,
|
|
28
|
+
BackupFormat,
|
|
29
|
+
RestoreResult,
|
|
30
|
+
DumpResult,
|
|
31
|
+
StatusResult,
|
|
32
|
+
} from '../../types'
|
|
33
|
+
|
|
34
|
+
const execAsync = promisify(exec)
|
|
35
|
+
|
|
36
|
+
const ENGINE = 'mysql'
|
|
37
|
+
const engineDef = getEngineDefaults(ENGINE)
|
|
38
|
+
|
|
39
|
+
export class MySQLEngine extends BaseEngine {
|
|
40
|
+
name = ENGINE
|
|
41
|
+
displayName = 'MySQL'
|
|
42
|
+
defaultPort = engineDef.defaultPort
|
|
43
|
+
supportedVersions = engineDef.supportedVersions
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch available versions from system
|
|
47
|
+
* Unlike PostgreSQL which downloads binaries, MySQL uses system-installed versions
|
|
48
|
+
*/
|
|
49
|
+
async fetchAvailableVersions(): Promise<Record<string, string[]>> {
|
|
50
|
+
const installed = await detectInstalledVersions()
|
|
51
|
+
const versions: Record<string, string[]> = {}
|
|
52
|
+
|
|
53
|
+
for (const [major, full] of Object.entries(installed)) {
|
|
54
|
+
versions[major] = [full]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// If no versions found, return supported versions as placeholders
|
|
58
|
+
if (Object.keys(versions).length === 0) {
|
|
59
|
+
for (const v of this.supportedVersions) {
|
|
60
|
+
versions[v] = [v]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return versions
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get binary download URL - not applicable for MySQL (uses system binaries)
|
|
69
|
+
*/
|
|
70
|
+
getBinaryUrl(_version: string, _platform: string, _arch: string): string {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'MySQL uses system-installed binaries. ' + getInstallInstructions(),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Verify that MySQL binaries are available
|
|
78
|
+
*/
|
|
79
|
+
async verifyBinary(_binPath: string): Promise<boolean> {
|
|
80
|
+
const mysqld = await getMysqldPath()
|
|
81
|
+
return mysqld !== null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if MySQL is installed
|
|
86
|
+
*/
|
|
87
|
+
async isBinaryInstalled(_version: string): Promise<boolean> {
|
|
88
|
+
const mysqld = await getMysqldPath()
|
|
89
|
+
return mysqld !== null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ensure MySQL binaries are available (just checks system installation)
|
|
94
|
+
*/
|
|
95
|
+
async ensureBinaries(
|
|
96
|
+
_version: string,
|
|
97
|
+
_onProgress?: ProgressCallback,
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const mysqld = await getMysqldPath()
|
|
100
|
+
if (!mysqld) {
|
|
101
|
+
throw new Error(getInstallInstructions())
|
|
102
|
+
}
|
|
103
|
+
return mysqld
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Initialize a new MySQL/MariaDB data directory
|
|
108
|
+
* MySQL: mysqld --initialize-insecure --datadir={dir}
|
|
109
|
+
* MariaDB: mysql_install_db --datadir={dir} --auth-root-authentication-method=normal
|
|
110
|
+
*/
|
|
111
|
+
async initDataDir(
|
|
112
|
+
containerName: string,
|
|
113
|
+
_version: string,
|
|
114
|
+
_options: Record<string, unknown> = {},
|
|
115
|
+
): Promise<string> {
|
|
116
|
+
const dataDir = paths.getContainerDataPath(containerName, { engine: ENGINE })
|
|
117
|
+
|
|
118
|
+
// Create data directory if it doesn't exist
|
|
119
|
+
if (!existsSync(dataDir)) {
|
|
120
|
+
await mkdir(dataDir, { recursive: true })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check if we're using MariaDB or MySQL
|
|
124
|
+
const usingMariaDB = await isMariaDB()
|
|
125
|
+
|
|
126
|
+
if (usingMariaDB) {
|
|
127
|
+
// MariaDB uses mysql_install_db or mariadb-install-db
|
|
128
|
+
const installDb =
|
|
129
|
+
(await getMariadbInstallDbPath()) || (await getMysqlInstallDbPath())
|
|
130
|
+
if (!installDb) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'MariaDB detected but mysql_install_db not found.\n' +
|
|
133
|
+
'Install MariaDB server package which includes the initialization script.',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// MariaDB initialization
|
|
138
|
+
// --auth-root-authentication-method=normal allows passwordless root login via socket
|
|
139
|
+
const args = [
|
|
140
|
+
`--datadir=${dataDir}`,
|
|
141
|
+
`--user=${process.env.USER || 'mysql'}`,
|
|
142
|
+
'--auth-root-authentication-method=normal',
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const proc = spawn(installDb, args, {
|
|
147
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
let stdout = ''
|
|
151
|
+
let stderr = ''
|
|
152
|
+
|
|
153
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
154
|
+
stdout += data.toString()
|
|
155
|
+
})
|
|
156
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
157
|
+
stderr += data.toString()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
proc.on('close', (code) => {
|
|
161
|
+
if (code === 0) {
|
|
162
|
+
resolve(dataDir)
|
|
163
|
+
} else {
|
|
164
|
+
reject(
|
|
165
|
+
new Error(
|
|
166
|
+
`MariaDB initialization failed with code ${code}: ${stderr || stdout}`,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
proc.on('error', reject)
|
|
173
|
+
})
|
|
174
|
+
} else {
|
|
175
|
+
// MySQL uses mysqld --initialize-insecure
|
|
176
|
+
const mysqld = await getMysqldPath()
|
|
177
|
+
if (!mysqld) {
|
|
178
|
+
throw new Error(getInstallInstructions())
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// MySQL initialization
|
|
182
|
+
// --initialize-insecure creates root user without password (for local dev)
|
|
183
|
+
const args = [
|
|
184
|
+
'--initialize-insecure',
|
|
185
|
+
`--datadir=${dataDir}`,
|
|
186
|
+
`--user=${process.env.USER || 'mysql'}`,
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const proc = spawn(mysqld, args, {
|
|
191
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
let stdout = ''
|
|
195
|
+
let stderr = ''
|
|
196
|
+
|
|
197
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
198
|
+
stdout += data.toString()
|
|
199
|
+
})
|
|
200
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
201
|
+
stderr += data.toString()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
proc.on('close', (code) => {
|
|
205
|
+
if (code === 0) {
|
|
206
|
+
resolve(dataDir)
|
|
207
|
+
} else {
|
|
208
|
+
reject(
|
|
209
|
+
new Error(
|
|
210
|
+
`MySQL initialization failed with code ${code}: ${stderr || stdout}`,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
proc.on('error', reject)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Start MySQL server
|
|
223
|
+
* CLI wrapper: mysqld_safe --datadir={dir} --port={port} &
|
|
224
|
+
*/
|
|
225
|
+
async start(
|
|
226
|
+
container: ContainerConfig,
|
|
227
|
+
onProgress?: ProgressCallback,
|
|
228
|
+
): Promise<{ port: number; connectionString: string }> {
|
|
229
|
+
const { name, port } = container
|
|
230
|
+
|
|
231
|
+
const mysqld = await getMysqldPath()
|
|
232
|
+
if (!mysqld) {
|
|
233
|
+
throw new Error(getInstallInstructions())
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
|
|
237
|
+
const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
|
|
238
|
+
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
239
|
+
const socketFile = join(
|
|
240
|
+
paths.getContainerPath(name, { engine: ENGINE }),
|
|
241
|
+
'mysql.sock',
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
onProgress?.({ stage: 'starting', message: 'Starting MySQL...' })
|
|
245
|
+
|
|
246
|
+
// Start mysqld directly in background
|
|
247
|
+
// Note: We use --initialize-insecure during init which creates root without password
|
|
248
|
+
// This allows passwordless local connections without --skip-grant-tables
|
|
249
|
+
// (--skip-grant-tables disables TCP networking in MySQL 8+)
|
|
250
|
+
const args = [
|
|
251
|
+
`--datadir=${dataDir}`,
|
|
252
|
+
`--port=${port}`,
|
|
253
|
+
`--socket=${socketFile}`,
|
|
254
|
+
`--pid-file=${pidFile}`,
|
|
255
|
+
`--log-error=${logFile}`,
|
|
256
|
+
'--bind-address=127.0.0.1',
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
const proc = spawn(mysqld, args, {
|
|
261
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
262
|
+
detached: true,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
proc.unref()
|
|
266
|
+
|
|
267
|
+
// Give MySQL a moment to start
|
|
268
|
+
setTimeout(async () => {
|
|
269
|
+
// Write PID file manually since we're running detached
|
|
270
|
+
try {
|
|
271
|
+
await writeFile(pidFile, String(proc.pid))
|
|
272
|
+
} catch {
|
|
273
|
+
// PID file might be written by mysqld itself
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Wait for MySQL to be ready
|
|
277
|
+
let attempts = 0
|
|
278
|
+
const maxAttempts = 30
|
|
279
|
+
const checkInterval = 500
|
|
280
|
+
|
|
281
|
+
const checkReady = async () => {
|
|
282
|
+
attempts++
|
|
283
|
+
try {
|
|
284
|
+
const mysqladmin = await getMysqladminPath()
|
|
285
|
+
if (mysqladmin) {
|
|
286
|
+
await execAsync(
|
|
287
|
+
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
|
|
288
|
+
)
|
|
289
|
+
resolve({
|
|
290
|
+
port,
|
|
291
|
+
connectionString: this.getConnectionString(container),
|
|
292
|
+
})
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
if (attempts < maxAttempts) {
|
|
297
|
+
setTimeout(checkReady, checkInterval)
|
|
298
|
+
} else {
|
|
299
|
+
reject(new Error('MySQL failed to start within timeout'))
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
checkReady()
|
|
305
|
+
}, 1000)
|
|
306
|
+
|
|
307
|
+
proc.on('error', reject)
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Stop MySQL server
|
|
313
|
+
* CLI wrapper: mysqladmin -u root -P {port} shutdown
|
|
314
|
+
*/
|
|
315
|
+
async stop(container: ContainerConfig): Promise<void> {
|
|
316
|
+
const { name, port } = container
|
|
317
|
+
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
318
|
+
|
|
319
|
+
// Try graceful shutdown first with mysqladmin
|
|
320
|
+
const mysqladmin = await getMysqladminPath()
|
|
321
|
+
if (mysqladmin) {
|
|
322
|
+
try {
|
|
323
|
+
await execAsync(
|
|
324
|
+
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root shutdown`,
|
|
325
|
+
)
|
|
326
|
+
} catch {
|
|
327
|
+
// Fall back to killing the process
|
|
328
|
+
if (existsSync(pidFile)) {
|
|
329
|
+
try {
|
|
330
|
+
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
331
|
+
process.kill(pid, 'SIGTERM')
|
|
332
|
+
} catch {
|
|
333
|
+
// Process might already be dead
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else if (existsSync(pidFile)) {
|
|
338
|
+
// No mysqladmin, kill by PID
|
|
339
|
+
try {
|
|
340
|
+
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
341
|
+
process.kill(pid, 'SIGTERM')
|
|
342
|
+
} catch {
|
|
343
|
+
// Process might already be dead
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Wait for the process to actually stop
|
|
348
|
+
const maxWaitMs = 10000
|
|
349
|
+
const checkIntervalMs = 200
|
|
350
|
+
const startTime = Date.now()
|
|
351
|
+
|
|
352
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
353
|
+
// Check if PID file is gone or process is dead
|
|
354
|
+
if (!existsSync(pidFile)) {
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
360
|
+
// Check if process is still running (signal 0 doesn't kill, just checks)
|
|
361
|
+
process.kill(pid, 0)
|
|
362
|
+
// Process still running, wait a bit
|
|
363
|
+
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs))
|
|
364
|
+
} catch {
|
|
365
|
+
// Process is dead, remove stale PID file if it exists
|
|
366
|
+
try {
|
|
367
|
+
await rm(pidFile, { force: true })
|
|
368
|
+
} catch {
|
|
369
|
+
// Ignore
|
|
370
|
+
}
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Timeout - force kill if still running
|
|
376
|
+
if (existsSync(pidFile)) {
|
|
377
|
+
try {
|
|
378
|
+
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
379
|
+
process.kill(pid, 'SIGKILL')
|
|
380
|
+
await rm(pidFile, { force: true })
|
|
381
|
+
} catch {
|
|
382
|
+
// Ignore
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Get MySQL server status
|
|
389
|
+
*/
|
|
390
|
+
async status(container: ContainerConfig): Promise<StatusResult> {
|
|
391
|
+
const { name, port } = container
|
|
392
|
+
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
393
|
+
|
|
394
|
+
// Check if PID file exists
|
|
395
|
+
if (!existsSync(pidFile)) {
|
|
396
|
+
return { running: false, message: 'MySQL is not running' }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Try to ping MySQL
|
|
400
|
+
const mysqladmin = await getMysqladminPath()
|
|
401
|
+
if (mysqladmin) {
|
|
402
|
+
try {
|
|
403
|
+
await execAsync(
|
|
404
|
+
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
|
|
405
|
+
)
|
|
406
|
+
return { running: true, message: 'MySQL is running' }
|
|
407
|
+
} catch {
|
|
408
|
+
return { running: false, message: 'MySQL is not responding' }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Fall back to checking PID
|
|
413
|
+
try {
|
|
414
|
+
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
415
|
+
process.kill(pid, 0) // Check if process exists
|
|
416
|
+
return { running: true, message: `MySQL is running (PID: ${pid})` }
|
|
417
|
+
} catch {
|
|
418
|
+
return { running: false, message: 'MySQL is not running' }
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Detect backup format
|
|
424
|
+
* MySQL dumps are typically SQL files
|
|
425
|
+
*/
|
|
426
|
+
async detectBackupFormat(filePath: string): Promise<BackupFormat> {
|
|
427
|
+
// Read first few bytes to detect format
|
|
428
|
+
const buffer = Buffer.alloc(64)
|
|
429
|
+
const { open } = await import('fs/promises')
|
|
430
|
+
const file = await open(filePath, 'r')
|
|
431
|
+
await file.read(buffer, 0, 64, 0)
|
|
432
|
+
await file.close()
|
|
433
|
+
|
|
434
|
+
const header = buffer.toString('utf8')
|
|
435
|
+
|
|
436
|
+
// Check for MySQL dump markers
|
|
437
|
+
if (
|
|
438
|
+
header.includes('-- MySQL dump') ||
|
|
439
|
+
header.includes('-- MariaDB dump')
|
|
440
|
+
) {
|
|
441
|
+
return {
|
|
442
|
+
format: 'sql',
|
|
443
|
+
description: 'MySQL SQL dump',
|
|
444
|
+
restoreCommand: 'mysql',
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Default to SQL format
|
|
449
|
+
return {
|
|
450
|
+
format: 'sql',
|
|
451
|
+
description: 'SQL file',
|
|
452
|
+
restoreCommand: 'mysql',
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Restore a backup
|
|
458
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
|
|
459
|
+
*/
|
|
460
|
+
async restore(
|
|
461
|
+
container: ContainerConfig,
|
|
462
|
+
backupPath: string,
|
|
463
|
+
options: Record<string, unknown> = {},
|
|
464
|
+
): Promise<RestoreResult> {
|
|
465
|
+
const { port } = container
|
|
466
|
+
const database = (options.database as string) || container.database
|
|
467
|
+
|
|
468
|
+
// Create the database if it doesn't exist
|
|
469
|
+
if (options.createDatabase !== false) {
|
|
470
|
+
await this.createDatabase(container, database)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const mysql = await getMysqlClientPath()
|
|
474
|
+
if (!mysql) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
'mysql client not found. Install MySQL client tools:\n' +
|
|
477
|
+
' macOS: brew install mysql-client\n' +
|
|
478
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Restore using mysql client
|
|
483
|
+
// CLI: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
|
|
484
|
+
return new Promise((resolve, reject) => {
|
|
485
|
+
const args = [
|
|
486
|
+
'-h',
|
|
487
|
+
'127.0.0.1',
|
|
488
|
+
'-P',
|
|
489
|
+
String(port),
|
|
490
|
+
'-u',
|
|
491
|
+
engineDef.superuser,
|
|
492
|
+
database,
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
const proc = spawn(mysql, args, {
|
|
496
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// Pipe backup file to stdin
|
|
500
|
+
const fileStream = createReadStream(backupPath)
|
|
501
|
+
fileStream.pipe(proc.stdin)
|
|
502
|
+
|
|
503
|
+
let stdout = ''
|
|
504
|
+
let stderr = ''
|
|
505
|
+
|
|
506
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
507
|
+
stdout += data.toString()
|
|
508
|
+
})
|
|
509
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
510
|
+
stderr += data.toString()
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
proc.on('close', (code) => {
|
|
514
|
+
resolve({
|
|
515
|
+
format: 'sql',
|
|
516
|
+
stdout,
|
|
517
|
+
stderr,
|
|
518
|
+
code: code ?? undefined,
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
proc.on('error', reject)
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Get connection string
|
|
528
|
+
*/
|
|
529
|
+
getConnectionString(container: ContainerConfig, database?: string): string {
|
|
530
|
+
const { port } = container
|
|
531
|
+
const db = database || container.database || 'mysql'
|
|
532
|
+
return `mysql://${engineDef.superuser}@127.0.0.1:${port}/${db}`
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Open mysql interactive shell
|
|
537
|
+
* Spawn interactive: mysql -h 127.0.0.1 -P {port} -u root {db}
|
|
538
|
+
*/
|
|
539
|
+
async connect(container: ContainerConfig, database?: string): Promise<void> {
|
|
540
|
+
const { port } = container
|
|
541
|
+
const db = database || container.database || 'mysql'
|
|
542
|
+
|
|
543
|
+
const mysql = await getMysqlClientPath()
|
|
544
|
+
if (!mysql) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
'mysql client not found. Install MySQL client tools:\n' +
|
|
547
|
+
' macOS: brew install mysql-client\n' +
|
|
548
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return new Promise((resolve, reject) => {
|
|
553
|
+
const proc = spawn(
|
|
554
|
+
mysql,
|
|
555
|
+
['-h', '127.0.0.1', '-P', String(port), '-u', engineDef.superuser, db],
|
|
556
|
+
{ stdio: 'inherit' },
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
proc.on('error', reject)
|
|
560
|
+
proc.on('close', () => resolve())
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Create a new database
|
|
566
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'CREATE DATABASE `{db}`'
|
|
567
|
+
*/
|
|
568
|
+
async createDatabase(
|
|
569
|
+
container: ContainerConfig,
|
|
570
|
+
database: string,
|
|
571
|
+
): Promise<void> {
|
|
572
|
+
const { port } = container
|
|
573
|
+
|
|
574
|
+
const mysql = await getMysqlClientPath()
|
|
575
|
+
if (!mysql) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
'mysql client not found. Install MySQL client tools:\n' +
|
|
578
|
+
' macOS: brew install mysql-client\n' +
|
|
579
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
// Use backticks for MySQL database names
|
|
585
|
+
await execAsync(
|
|
586
|
+
`"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'CREATE DATABASE IF NOT EXISTS \`${database}\`'`,
|
|
587
|
+
)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
const err = error as Error
|
|
590
|
+
// Ignore "database exists" error
|
|
591
|
+
if (!err.message.includes('database exists')) {
|
|
592
|
+
throw error
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Drop a database
|
|
599
|
+
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'DROP DATABASE IF EXISTS `{db}`'
|
|
600
|
+
*/
|
|
601
|
+
async dropDatabase(
|
|
602
|
+
container: ContainerConfig,
|
|
603
|
+
database: string,
|
|
604
|
+
): Promise<void> {
|
|
605
|
+
const { port } = container
|
|
606
|
+
|
|
607
|
+
const mysql = await getMysqlClientPath()
|
|
608
|
+
if (!mysql) {
|
|
609
|
+
throw new Error('mysql client not found.')
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
await execAsync(
|
|
614
|
+
`"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'DROP DATABASE IF EXISTS \`${database}\`'`,
|
|
615
|
+
)
|
|
616
|
+
} catch (error) {
|
|
617
|
+
const err = error as Error
|
|
618
|
+
if (!err.message.includes("database doesn't exist")) {
|
|
619
|
+
throw error
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Create a dump from a remote database using a connection string
|
|
626
|
+
* CLI wrapper: mysqldump -h {host} -P {port} -u {user} -p{pass} {db} > {file}
|
|
627
|
+
*/
|
|
628
|
+
async dumpFromConnectionString(
|
|
629
|
+
connectionString: string,
|
|
630
|
+
outputPath: string,
|
|
631
|
+
): Promise<DumpResult> {
|
|
632
|
+
const mysqldump = await getMysqldumpPath()
|
|
633
|
+
if (!mysqldump) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
'mysqldump not found. Install MySQL client tools:\n' +
|
|
636
|
+
' macOS: brew install mysql-client\n' +
|
|
637
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Parse MySQL connection string: mysql://user:pass@host:port/dbname
|
|
642
|
+
const url = new URL(connectionString)
|
|
643
|
+
const host = url.hostname
|
|
644
|
+
const port = url.port || '3306'
|
|
645
|
+
const user = url.username || 'root'
|
|
646
|
+
const password = url.password
|
|
647
|
+
const database = url.pathname.slice(1) // Remove leading /
|
|
648
|
+
|
|
649
|
+
const args = [
|
|
650
|
+
'-h',
|
|
651
|
+
host,
|
|
652
|
+
'-P',
|
|
653
|
+
port,
|
|
654
|
+
'-u',
|
|
655
|
+
user,
|
|
656
|
+
'--result-file',
|
|
657
|
+
outputPath,
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
if (password) {
|
|
661
|
+
args.push(`-p${password}`)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
args.push(database)
|
|
665
|
+
|
|
666
|
+
return new Promise((resolve, reject) => {
|
|
667
|
+
const proc = spawn(mysqldump, args, {
|
|
668
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
let stdout = ''
|
|
672
|
+
let stderr = ''
|
|
673
|
+
|
|
674
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
675
|
+
stdout += data.toString()
|
|
676
|
+
})
|
|
677
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
678
|
+
stderr += data.toString()
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
proc.on('error', reject)
|
|
682
|
+
|
|
683
|
+
proc.on('close', (code) => {
|
|
684
|
+
if (code === 0) {
|
|
685
|
+
resolve({
|
|
686
|
+
filePath: outputPath,
|
|
687
|
+
stdout,
|
|
688
|
+
stderr,
|
|
689
|
+
code,
|
|
690
|
+
})
|
|
691
|
+
} else {
|
|
692
|
+
reject(new Error(stderr || `mysqldump exited with code ${code}`))
|
|
693
|
+
}
|
|
694
|
+
})
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export const mysqlEngine = new MySQLEngine()
|