spindb 0.5.2 → 0.5.4
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 +188 -9
- package/cli/commands/connect.ts +334 -105
- package/cli/commands/create.ts +106 -67
- package/cli/commands/deps.ts +19 -4
- package/cli/commands/edit.ts +245 -0
- package/cli/commands/engines.ts +434 -0
- package/cli/commands/info.ts +279 -0
- package/cli/commands/list.ts +1 -1
- package/cli/commands/menu.ts +664 -167
- package/cli/commands/restore.ts +11 -25
- package/cli/commands/start.ts +25 -20
- package/cli/commands/url.ts +79 -0
- package/cli/index.ts +9 -3
- package/cli/ui/prompts.ts +20 -12
- package/cli/ui/theme.ts +1 -1
- package/config/engine-defaults.ts +24 -1
- package/config/os-dependencies.ts +151 -113
- package/config/paths.ts +7 -36
- package/core/binary-manager.ts +12 -6
- package/core/config-manager.ts +17 -5
- package/core/dependency-manager.ts +144 -15
- package/core/error-handler.ts +336 -0
- package/core/platform-service.ts +634 -0
- package/core/port-manager.ts +11 -3
- package/core/process-manager.ts +12 -2
- package/core/start-with-retry.ts +167 -0
- package/core/transaction-manager.ts +170 -0
- package/engines/mysql/binary-detection.ts +177 -100
- package/engines/mysql/index.ts +240 -131
- package/engines/mysql/restore.ts +257 -0
- package/engines/mysql/version-validator.ts +373 -0
- package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
- package/engines/postgresql/binary-urls.ts +5 -3
- package/engines/postgresql/index.ts +35 -4
- package/engines/postgresql/restore.ts +54 -5
- package/engines/postgresql/version-validator.ts +262 -0
- package/package.json +6 -2
- package/cli/commands/postgres-tools.ts +0 -216
package/engines/mysql/index.ts
CHANGED
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawn, exec } from 'child_process'
|
|
7
7
|
import { promisify } from 'util'
|
|
8
|
-
import { existsSync
|
|
9
|
-
import { mkdir, writeFile, readFile,
|
|
8
|
+
import { existsSync } from 'fs'
|
|
9
|
+
import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
|
|
10
10
|
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
|
+
logDebug,
|
|
16
|
+
logWarning,
|
|
17
|
+
ErrorCodes,
|
|
18
|
+
SpinDBError,
|
|
19
|
+
} from '../../core/error-handler'
|
|
14
20
|
import {
|
|
15
21
|
getMysqldPath,
|
|
16
22
|
getMysqlClientPath,
|
|
@@ -22,6 +28,11 @@ import {
|
|
|
22
28
|
detectInstalledVersions,
|
|
23
29
|
getInstallInstructions,
|
|
24
30
|
} from './binary-detection'
|
|
31
|
+
import {
|
|
32
|
+
detectBackupFormat as detectBackupFormatImpl,
|
|
33
|
+
restoreBackup,
|
|
34
|
+
parseConnectionString,
|
|
35
|
+
} from './restore'
|
|
25
36
|
import type {
|
|
26
37
|
ContainerConfig,
|
|
27
38
|
ProgressCallback,
|
|
@@ -31,6 +42,10 @@ import type {
|
|
|
31
42
|
StatusResult,
|
|
32
43
|
} from '../../types'
|
|
33
44
|
|
|
45
|
+
// Re-export modules for external access
|
|
46
|
+
export * from './version-validator'
|
|
47
|
+
export * from './restore'
|
|
48
|
+
|
|
34
49
|
const execAsync = promisify(exec)
|
|
35
50
|
|
|
36
51
|
const ENGINE = 'mysql'
|
|
@@ -113,7 +128,9 @@ export class MySQLEngine extends BaseEngine {
|
|
|
113
128
|
_version: string,
|
|
114
129
|
_options: Record<string, unknown> = {},
|
|
115
130
|
): Promise<string> {
|
|
116
|
-
const dataDir = paths.getContainerDataPath(containerName, {
|
|
131
|
+
const dataDir = paths.getContainerDataPath(containerName, {
|
|
132
|
+
engine: ENGINE,
|
|
133
|
+
})
|
|
117
134
|
|
|
118
135
|
// Create data directory if it doesn't exist
|
|
119
136
|
if (!existsSync(dataDir)) {
|
|
@@ -316,74 +333,240 @@ export class MySQLEngine extends BaseEngine {
|
|
|
316
333
|
const { name, port } = container
|
|
317
334
|
const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
|
|
318
335
|
|
|
319
|
-
|
|
336
|
+
logDebug(`Stopping MySQL container "${name}" on port ${port}`)
|
|
337
|
+
|
|
338
|
+
// Step 1: Get PID with validation
|
|
339
|
+
const pid = await this.getValidatedPid(pidFile)
|
|
340
|
+
if (pid === null) {
|
|
341
|
+
// No valid PID file - check if process might still be running on port
|
|
342
|
+
logDebug('No valid PID, checking if MySQL is responding on port')
|
|
343
|
+
const mysqladmin = await getMysqladminPath()
|
|
344
|
+
if (mysqladmin) {
|
|
345
|
+
try {
|
|
346
|
+
await execAsync(
|
|
347
|
+
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
|
|
348
|
+
{ timeout: 2000 },
|
|
349
|
+
)
|
|
350
|
+
// MySQL is responding - try graceful shutdown even without PID
|
|
351
|
+
logWarning(`MySQL responding on port ${port} but no valid PID file`)
|
|
352
|
+
await this.gracefulShutdown(port)
|
|
353
|
+
} catch {
|
|
354
|
+
// MySQL not responding, nothing to stop
|
|
355
|
+
logDebug('MySQL not responding, nothing to stop')
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Step 2: Try graceful shutdown
|
|
362
|
+
const gracefulSuccess = await this.gracefulShutdown(port, pid)
|
|
363
|
+
if (gracefulSuccess) {
|
|
364
|
+
await this.cleanupPidFile(pidFile)
|
|
365
|
+
logDebug('MySQL stopped gracefully')
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Step 3: Force kill with escalation
|
|
370
|
+
await this.forceKillWithEscalation(pid, pidFile)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Get and validate PID from PID file
|
|
375
|
+
* Returns null if PID file doesn't exist, is corrupt, or references dead process
|
|
376
|
+
*/
|
|
377
|
+
private async getValidatedPid(pidFile: string): Promise<number | null> {
|
|
378
|
+
if (!existsSync(pidFile)) {
|
|
379
|
+
logDebug('PID file does not exist')
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const content = await readFile(pidFile, 'utf8')
|
|
385
|
+
const pid = parseInt(content.trim(), 10)
|
|
386
|
+
|
|
387
|
+
if (isNaN(pid) || pid <= 0) {
|
|
388
|
+
logWarning(`PID file contains invalid value: "${content.trim()}"`, {
|
|
389
|
+
code: ErrorCodes.PID_FILE_CORRUPT,
|
|
390
|
+
pidFile,
|
|
391
|
+
})
|
|
392
|
+
// Clean up corrupt PID file
|
|
393
|
+
await this.cleanupPidFile(pidFile)
|
|
394
|
+
return null
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Verify process exists
|
|
398
|
+
try {
|
|
399
|
+
process.kill(pid, 0) // Signal 0 = check existence
|
|
400
|
+
logDebug(`Validated PID ${pid}`)
|
|
401
|
+
return pid
|
|
402
|
+
} catch {
|
|
403
|
+
logWarning(`PID file references non-existent process ${pid}`, {
|
|
404
|
+
code: ErrorCodes.PID_FILE_STALE,
|
|
405
|
+
pidFile,
|
|
406
|
+
})
|
|
407
|
+
// Clean up stale PID file
|
|
408
|
+
await this.cleanupPidFile(pidFile)
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
} catch (err) {
|
|
412
|
+
const e = err as NodeJS.ErrnoException
|
|
413
|
+
if (e.code !== 'ENOENT') {
|
|
414
|
+
logWarning(`Failed to read PID file: ${e.message}`, {
|
|
415
|
+
pidFile,
|
|
416
|
+
errorCode: e.code,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Attempt graceful shutdown via mysqladmin
|
|
425
|
+
*/
|
|
426
|
+
private async gracefulShutdown(
|
|
427
|
+
port: number,
|
|
428
|
+
pid?: number,
|
|
429
|
+
timeoutMs = 10000,
|
|
430
|
+
): Promise<boolean> {
|
|
320
431
|
const mysqladmin = await getMysqladminPath()
|
|
432
|
+
|
|
321
433
|
if (mysqladmin) {
|
|
322
434
|
try {
|
|
435
|
+
logDebug('Attempting mysqladmin shutdown')
|
|
323
436
|
await execAsync(
|
|
324
437
|
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root shutdown`,
|
|
438
|
+
{ timeout: 5000 },
|
|
325
439
|
)
|
|
326
|
-
} catch {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
331
|
-
process.kill(pid, 'SIGTERM')
|
|
332
|
-
} catch {
|
|
333
|
-
// Process might already be dead
|
|
334
|
-
}
|
|
335
|
-
}
|
|
440
|
+
} catch (err) {
|
|
441
|
+
const e = err as Error
|
|
442
|
+
logDebug(`mysqladmin shutdown failed: ${e.message}`)
|
|
443
|
+
// Continue to wait for process to die or send SIGTERM
|
|
336
444
|
}
|
|
337
|
-
} else if (
|
|
338
|
-
// No mysqladmin,
|
|
445
|
+
} else if (pid) {
|
|
446
|
+
// No mysqladmin available, send SIGTERM
|
|
447
|
+
logDebug('No mysqladmin available, sending SIGTERM')
|
|
339
448
|
try {
|
|
340
|
-
const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
|
|
341
449
|
process.kill(pid, 'SIGTERM')
|
|
342
450
|
} catch {
|
|
343
|
-
// Process
|
|
451
|
+
// Process may already be dead
|
|
452
|
+
return true
|
|
344
453
|
}
|
|
345
454
|
}
|
|
346
455
|
|
|
347
|
-
// Wait for
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
456
|
+
// Wait for process to terminate
|
|
457
|
+
if (pid) {
|
|
458
|
+
const startTime = Date.now()
|
|
459
|
+
const checkIntervalMs = 200
|
|
351
460
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
461
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
462
|
+
try {
|
|
463
|
+
process.kill(pid, 0)
|
|
464
|
+
await this.sleep(checkIntervalMs)
|
|
465
|
+
} catch {
|
|
466
|
+
// Process is gone
|
|
467
|
+
logDebug(`Process ${pid} terminated after graceful shutdown`)
|
|
468
|
+
return true
|
|
469
|
+
}
|
|
356
470
|
}
|
|
357
471
|
|
|
472
|
+
logDebug(`Graceful shutdown timed out after ${timeoutMs}ms`)
|
|
473
|
+
return false
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// No PID to check, assume success if mysqladmin didn't throw
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Force kill with signal escalation (SIGTERM -> SIGKILL)
|
|
482
|
+
*/
|
|
483
|
+
private async forceKillWithEscalation(
|
|
484
|
+
pid: number,
|
|
485
|
+
pidFile: string,
|
|
486
|
+
): Promise<void> {
|
|
487
|
+
logWarning(`Graceful shutdown failed, force killing process ${pid}`)
|
|
488
|
+
|
|
489
|
+
// Try SIGTERM first (if not already sent in graceful shutdown)
|
|
490
|
+
try {
|
|
491
|
+
process.kill(pid, 'SIGTERM')
|
|
492
|
+
await this.sleep(2000)
|
|
493
|
+
|
|
494
|
+
// Check if still running
|
|
358
495
|
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
496
|
process.kill(pid, 0)
|
|
362
|
-
// Process still running, wait a bit
|
|
363
|
-
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs))
|
|
364
497
|
} catch {
|
|
365
|
-
// Process
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
498
|
+
// Process terminated
|
|
499
|
+
logDebug(`Process ${pid} terminated after SIGTERM`)
|
|
500
|
+
await this.cleanupPidFile(pidFile)
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
} catch (err) {
|
|
504
|
+
const e = err as NodeJS.ErrnoException
|
|
505
|
+
if (e.code === 'ESRCH') {
|
|
506
|
+
// Process already dead
|
|
507
|
+
await this.cleanupPidFile(pidFile)
|
|
371
508
|
return
|
|
372
509
|
}
|
|
510
|
+
logDebug(`SIGTERM failed: ${e.message}`)
|
|
373
511
|
}
|
|
374
512
|
|
|
375
|
-
//
|
|
376
|
-
|
|
513
|
+
// Escalate to SIGKILL
|
|
514
|
+
logWarning(`SIGTERM failed, escalating to SIGKILL for process ${pid}`)
|
|
515
|
+
try {
|
|
516
|
+
process.kill(pid, 'SIGKILL')
|
|
517
|
+
await this.sleep(1000)
|
|
518
|
+
|
|
519
|
+
// Verify process is gone
|
|
377
520
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
521
|
+
process.kill(pid, 0)
|
|
522
|
+
// Process still running after SIGKILL - this is unexpected
|
|
523
|
+
throw new SpinDBError(
|
|
524
|
+
ErrorCodes.PROCESS_STOP_TIMEOUT,
|
|
525
|
+
`Failed to stop MySQL process ${pid} even with SIGKILL`,
|
|
526
|
+
'error',
|
|
527
|
+
`Try manually killing the process: kill -9 ${pid}`,
|
|
528
|
+
)
|
|
529
|
+
} catch (checkErr) {
|
|
530
|
+
const checkE = checkErr as NodeJS.ErrnoException
|
|
531
|
+
if (checkE instanceof SpinDBError) throw checkE
|
|
532
|
+
// Process is gone (ESRCH)
|
|
533
|
+
logDebug(`Process ${pid} terminated after SIGKILL`)
|
|
534
|
+
await this.cleanupPidFile(pidFile)
|
|
383
535
|
}
|
|
536
|
+
} catch (err) {
|
|
537
|
+
if (err instanceof SpinDBError) throw err
|
|
538
|
+
const e = err as NodeJS.ErrnoException
|
|
539
|
+
if (e.code === 'ESRCH') {
|
|
540
|
+
// Process already dead
|
|
541
|
+
await this.cleanupPidFile(pidFile)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
logDebug(`SIGKILL failed: ${e.message}`)
|
|
384
545
|
}
|
|
385
546
|
}
|
|
386
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Clean up PID file
|
|
550
|
+
*/
|
|
551
|
+
private async cleanupPidFile(pidFile: string): Promise<void> {
|
|
552
|
+
try {
|
|
553
|
+
await unlink(pidFile)
|
|
554
|
+
logDebug('PID file cleaned up')
|
|
555
|
+
} catch (err) {
|
|
556
|
+
const e = err as NodeJS.ErrnoException
|
|
557
|
+
if (e.code !== 'ENOENT') {
|
|
558
|
+
logDebug(`Failed to clean up PID file: ${e.message}`)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Sleep helper
|
|
565
|
+
*/
|
|
566
|
+
private sleep(ms: number): Promise<void> {
|
|
567
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
568
|
+
}
|
|
569
|
+
|
|
387
570
|
/**
|
|
388
571
|
* Get MySQL server status
|
|
389
572
|
*/
|
|
@@ -400,9 +583,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
400
583
|
const mysqladmin = await getMysqladminPath()
|
|
401
584
|
if (mysqladmin) {
|
|
402
585
|
try {
|
|
403
|
-
await execAsync(
|
|
404
|
-
`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
|
|
405
|
-
)
|
|
586
|
+
await execAsync(`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`)
|
|
406
587
|
return { running: true, message: 'MySQL is running' }
|
|
407
588
|
} catch {
|
|
408
589
|
return { running: false, message: 'MySQL is not responding' }
|
|
@@ -421,40 +602,15 @@ export class MySQLEngine extends BaseEngine {
|
|
|
421
602
|
|
|
422
603
|
/**
|
|
423
604
|
* Detect backup format
|
|
424
|
-
*
|
|
605
|
+
* Delegates to restore.ts module
|
|
425
606
|
*/
|
|
426
607
|
async detectBackupFormat(filePath: string): Promise<BackupFormat> {
|
|
427
|
-
|
|
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
|
-
}
|
|
608
|
+
return detectBackupFormatImpl(filePath)
|
|
454
609
|
}
|
|
455
610
|
|
|
456
611
|
/**
|
|
457
612
|
* Restore a backup
|
|
613
|
+
* Delegates to restore.ts module with version validation
|
|
458
614
|
* CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
|
|
459
615
|
*/
|
|
460
616
|
async restore(
|
|
@@ -470,56 +626,13 @@ export class MySQLEngine extends BaseEngine {
|
|
|
470
626
|
await this.createDatabase(container, database)
|
|
471
627
|
}
|
|
472
628
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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)
|
|
629
|
+
// Use the restore module with version validation
|
|
630
|
+
return restoreBackup(backupPath, {
|
|
631
|
+
port,
|
|
632
|
+
database,
|
|
633
|
+
user: engineDef.superuser,
|
|
634
|
+
createDatabase: false, // Already created above
|
|
635
|
+
validateVersion: options.validateVersion !== false,
|
|
523
636
|
})
|
|
524
637
|
}
|
|
525
638
|
|
|
@@ -638,13 +751,9 @@ export class MySQLEngine extends BaseEngine {
|
|
|
638
751
|
)
|
|
639
752
|
}
|
|
640
753
|
|
|
641
|
-
// Parse MySQL connection string
|
|
642
|
-
const
|
|
643
|
-
|
|
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 /
|
|
754
|
+
// Parse MySQL connection string using restore module helper
|
|
755
|
+
const { host, port, user, password, database } =
|
|
756
|
+
parseConnectionString(connectionString)
|
|
648
757
|
|
|
649
758
|
const args = [
|
|
650
759
|
'-h',
|