spindb 0.10.1 → 0.10.3
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 +42 -1
- package/cli/commands/create.ts +13 -15
- package/cli/commands/engines.ts +141 -39
- package/cli/ui/prompts.ts +30 -12
- package/cli/ui/theme.ts +0 -36
- package/core/binary-manager.ts +179 -2
- package/core/config-manager.ts +0 -55
- package/core/container-manager.ts +5 -55
- package/core/dependency-manager.ts +20 -1
- package/core/error-handler.ts +4 -48
- package/core/port-manager.ts +0 -19
- package/core/process-manager.ts +0 -24
- package/engines/postgresql/index.ts +87 -100
- package/engines/sqlite/index.ts +10 -97
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -283,9 +283,22 @@ spindb stop mydb
|
|
|
283
283
|
|
|
284
284
|
```bash
|
|
285
285
|
spindb delete mydb
|
|
286
|
-
spindb delete mydb --
|
|
286
|
+
spindb delete mydb --yes # Skip confirmation prompt
|
|
287
|
+
spindb delete mydb --force # Force stop if running
|
|
288
|
+
spindb delete mydb -fy # Both: force stop + skip confirmation
|
|
287
289
|
```
|
|
288
290
|
|
|
291
|
+
<details>
|
|
292
|
+
<summary>All options</summary>
|
|
293
|
+
|
|
294
|
+
| Option | Description |
|
|
295
|
+
|--------|-------------|
|
|
296
|
+
| `--force`, `-f` | Force stop if container is running before deleting |
|
|
297
|
+
| `--yes`, `-y` | Skip confirmation prompt (for scripts/automation) |
|
|
298
|
+
| `--json`, `-j` | Output result as JSON |
|
|
299
|
+
|
|
300
|
+
</details>
|
|
301
|
+
|
|
289
302
|
### Data Operations
|
|
290
303
|
|
|
291
304
|
#### `connect` - Open database shell
|
|
@@ -345,13 +358,41 @@ spindb backup mydb --sql
|
|
|
345
358
|
spindb backup mydb --dump
|
|
346
359
|
```
|
|
347
360
|
|
|
361
|
+
<details>
|
|
362
|
+
<summary>All options</summary>
|
|
363
|
+
|
|
364
|
+
| Option | Description |
|
|
365
|
+
|--------|-------------|
|
|
366
|
+
| `--database`, `-d` | Database to backup (defaults to primary) |
|
|
367
|
+
| `--name`, `-n` | Custom backup filename (without extension) |
|
|
368
|
+
| `--output`, `-o` | Output directory (defaults to current directory) |
|
|
369
|
+
| `--format` | Output format: `sql` or `dump` |
|
|
370
|
+
| `--sql` | Shorthand for `--format sql` |
|
|
371
|
+
| `--dump` | Shorthand for `--format dump` |
|
|
372
|
+
| `--json`, `-j` | Output result as JSON |
|
|
373
|
+
|
|
374
|
+
</details>
|
|
375
|
+
|
|
348
376
|
#### `restore` - Restore from backup
|
|
349
377
|
|
|
350
378
|
```bash
|
|
351
379
|
spindb restore mydb backup.dump
|
|
352
380
|
spindb restore mydb backup.sql --database my_app
|
|
381
|
+
spindb restore mydb --from-url "postgresql://user:pass@host/db"
|
|
353
382
|
```
|
|
354
383
|
|
|
384
|
+
<details>
|
|
385
|
+
<summary>All options</summary>
|
|
386
|
+
|
|
387
|
+
| Option | Description |
|
|
388
|
+
|--------|-------------|
|
|
389
|
+
| `--database`, `-d` | Target database name |
|
|
390
|
+
| `--from-url` | Pull data from a remote database connection string |
|
|
391
|
+
| `--force`, `-f` | Overwrite existing database without confirmation |
|
|
392
|
+
| `--json`, `-j` | Output result as JSON |
|
|
393
|
+
|
|
394
|
+
</details>
|
|
395
|
+
|
|
355
396
|
### Container Management
|
|
356
397
|
|
|
357
398
|
#### `list` - List all containers
|
package/cli/commands/create.ts
CHANGED
|
@@ -376,28 +376,26 @@ export const createCommand = new Command('create')
|
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
// For PostgreSQL,
|
|
380
|
-
//
|
|
379
|
+
// For PostgreSQL, ensure binaries FIRST - they include client tools (psql, pg_dump, etc.)
|
|
380
|
+
// ensureBinaries also registers tool paths in config cache so getMissingDependencies can find them
|
|
381
381
|
if (isPostgreSQL) {
|
|
382
382
|
const binarySpinner = createSpinner(
|
|
383
383
|
`Checking ${dbEngine.displayName} ${version} binaries...`,
|
|
384
384
|
)
|
|
385
385
|
binarySpinner.start()
|
|
386
386
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
binarySpinner.text = `Downloading ${dbEngine.displayName} ${version} binaries...`
|
|
394
|
-
await dbEngine.ensureBinaries(version, ({ message }) => {
|
|
387
|
+
// Always call ensureBinaries - it handles cached binaries gracefully
|
|
388
|
+
// and registers client tool paths in config (needed for dependency checks)
|
|
389
|
+
await dbEngine.ensureBinaries(version, ({ stage, message }) => {
|
|
390
|
+
if (stage === 'cached') {
|
|
391
|
+
binarySpinner.text = `${dbEngine.displayName} ${version} binaries ready (cached)`
|
|
392
|
+
} else {
|
|
395
393
|
binarySpinner.text = message
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
binarySpinner.succeed(
|
|
397
|
+
`${dbEngine.displayName} ${version} binaries ready`,
|
|
398
|
+
)
|
|
401
399
|
}
|
|
402
400
|
|
|
403
401
|
// Check dependencies (all engines need this)
|
package/cli/commands/engines.ts
CHANGED
|
@@ -7,9 +7,16 @@ import { getEngine } from '../../engines'
|
|
|
7
7
|
import { binaryManager } from '../../core/binary-manager'
|
|
8
8
|
import { paths } from '../../config/paths'
|
|
9
9
|
import { platformService } from '../../core/platform-service'
|
|
10
|
+
import {
|
|
11
|
+
detectPackageManager,
|
|
12
|
+
checkEngineDependencies,
|
|
13
|
+
installEngineDependencies,
|
|
14
|
+
getManualInstallInstructions,
|
|
15
|
+
getCurrentPlatform,
|
|
16
|
+
} from '../../core/dependency-manager'
|
|
10
17
|
import { promptConfirm } from '../ui/prompts'
|
|
11
18
|
import { createSpinner } from '../ui/spinner'
|
|
12
|
-
import { uiError, uiWarning, uiInfo, formatBytes } from '../ui/theme'
|
|
19
|
+
import { uiError, uiWarning, uiInfo, uiSuccess, formatBytes } from '../ui/theme'
|
|
13
20
|
import { getEngineIcon, ENGINE_ICONS } from '../constants'
|
|
14
21
|
import {
|
|
15
22
|
getInstalledEngines,
|
|
@@ -250,6 +257,80 @@ async function deleteEngine(
|
|
|
250
257
|
}
|
|
251
258
|
}
|
|
252
259
|
|
|
260
|
+
// Install an engine via system package manager
|
|
261
|
+
async function installEngineViaPackageManager(
|
|
262
|
+
engine: string,
|
|
263
|
+
displayName: string,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
// Check if already installed
|
|
266
|
+
const statuses = await checkEngineDependencies(engine)
|
|
267
|
+
const allInstalled = statuses.every((s) => s.installed)
|
|
268
|
+
|
|
269
|
+
if (allInstalled) {
|
|
270
|
+
console.log(uiInfo(`${displayName} is already installed.`))
|
|
271
|
+
for (const status of statuses) {
|
|
272
|
+
if (status.path) {
|
|
273
|
+
console.log(chalk.gray(` ${status.dependency.binary}: ${status.path}`))
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Detect package manager
|
|
280
|
+
const packageManager = await detectPackageManager()
|
|
281
|
+
|
|
282
|
+
if (!packageManager) {
|
|
283
|
+
console.error(uiError('No supported package manager found.'))
|
|
284
|
+
console.log()
|
|
285
|
+
console.log(chalk.yellow('Manual installation instructions:'))
|
|
286
|
+
const platform = getCurrentPlatform()
|
|
287
|
+
const missingDeps = statuses.filter((s) => !s.installed)
|
|
288
|
+
for (const status of missingDeps) {
|
|
289
|
+
const instructions = getManualInstallInstructions(
|
|
290
|
+
status.dependency,
|
|
291
|
+
platform,
|
|
292
|
+
)
|
|
293
|
+
console.log(chalk.gray(` ${status.dependency.name}:`))
|
|
294
|
+
for (const instruction of instructions) {
|
|
295
|
+
console.log(chalk.gray(` ${instruction}`))
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
process.exit(1)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
console.log(uiInfo(`Installing ${displayName} via ${packageManager.name}...`))
|
|
302
|
+
console.log()
|
|
303
|
+
|
|
304
|
+
// Install missing dependencies
|
|
305
|
+
const results = await installEngineDependencies(engine, packageManager)
|
|
306
|
+
|
|
307
|
+
// Report results
|
|
308
|
+
const succeeded = results.filter((r) => r.success)
|
|
309
|
+
const failed = results.filter((r) => !r.success)
|
|
310
|
+
|
|
311
|
+
if (succeeded.length > 0) {
|
|
312
|
+
console.log()
|
|
313
|
+
console.log(uiSuccess(`${displayName} installed successfully.`))
|
|
314
|
+
|
|
315
|
+
// Show installed paths
|
|
316
|
+
const newStatuses = await checkEngineDependencies(engine)
|
|
317
|
+
for (const status of newStatuses) {
|
|
318
|
+
if (status.installed && status.path) {
|
|
319
|
+
console.log(chalk.gray(` ${status.dependency.binary}: ${status.path}`))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (failed.length > 0) {
|
|
325
|
+
console.log()
|
|
326
|
+
console.error(uiError('Some components failed to install:'))
|
|
327
|
+
for (const result of failed) {
|
|
328
|
+
console.error(chalk.red(` ${result.dependency.name}: ${result.error}`))
|
|
329
|
+
}
|
|
330
|
+
process.exit(1)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
253
334
|
// Main engines command
|
|
254
335
|
export const enginesCommand = new Command('engines')
|
|
255
336
|
.description('Manage installed database engines')
|
|
@@ -287,53 +368,74 @@ enginesCommand
|
|
|
287
368
|
|
|
288
369
|
// Download subcommand
|
|
289
370
|
enginesCommand
|
|
290
|
-
.command('download <engine>
|
|
291
|
-
.description('Download engine binaries
|
|
292
|
-
.action(async (engineName: string, version
|
|
371
|
+
.command('download <engine> [version]')
|
|
372
|
+
.description('Download/install engine binaries')
|
|
373
|
+
.action(async (engineName: string, version?: string) => {
|
|
293
374
|
try {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
process.exit(1)
|
|
303
|
-
}
|
|
375
|
+
const normalizedEngine = engineName.toLowerCase()
|
|
376
|
+
|
|
377
|
+
// PostgreSQL: download binaries
|
|
378
|
+
if (['postgresql', 'pg', 'postgres'].includes(normalizedEngine)) {
|
|
379
|
+
if (!version) {
|
|
380
|
+
console.error(uiError('PostgreSQL requires a version (e.g., 17)'))
|
|
381
|
+
process.exit(1)
|
|
382
|
+
}
|
|
304
383
|
|
|
305
|
-
|
|
384
|
+
const engine = getEngine(Engine.PostgreSQL)
|
|
306
385
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (isInstalled) {
|
|
310
|
-
console.log(
|
|
311
|
-
uiInfo(`PostgreSQL ${version} binaries are already installed.`),
|
|
386
|
+
const spinner = createSpinner(
|
|
387
|
+
`Checking PostgreSQL ${version} binaries...`,
|
|
312
388
|
)
|
|
389
|
+
spinner.start()
|
|
390
|
+
|
|
391
|
+
// Always call ensureBinaries - it handles cached binaries gracefully
|
|
392
|
+
// and registers client tool paths in config (needed for dependency checks)
|
|
393
|
+
let wasCached = false
|
|
394
|
+
await engine.ensureBinaries(version, ({ stage, message }) => {
|
|
395
|
+
if (stage === 'cached') {
|
|
396
|
+
wasCached = true
|
|
397
|
+
spinner.text = `PostgreSQL ${version} binaries ready (cached)`
|
|
398
|
+
} else {
|
|
399
|
+
spinner.text = message
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
if (wasCached) {
|
|
404
|
+
spinner.succeed(`PostgreSQL ${version} binaries already installed`)
|
|
405
|
+
} else {
|
|
406
|
+
spinner.succeed(`PostgreSQL ${version} binaries downloaded`)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Show the path for reference
|
|
410
|
+
const { platform, arch } = platformService.getPlatformInfo()
|
|
411
|
+
const fullVersion = binaryManager.getFullVersion(version)
|
|
412
|
+
const binPath = paths.getBinaryPath({
|
|
413
|
+
engine: 'postgresql',
|
|
414
|
+
version: fullVersion,
|
|
415
|
+
platform,
|
|
416
|
+
arch,
|
|
417
|
+
})
|
|
418
|
+
console.log(chalk.gray(` Location: ${binPath}`))
|
|
313
419
|
return
|
|
314
420
|
}
|
|
315
421
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
await engine.ensureBinaries(version, ({ message }) => {
|
|
322
|
-
spinner.text = message
|
|
323
|
-
})
|
|
422
|
+
// MySQL and SQLite: install via system package manager
|
|
423
|
+
if (['mysql', 'mariadb'].includes(normalizedEngine)) {
|
|
424
|
+
await installEngineViaPackageManager('mysql', 'MySQL')
|
|
425
|
+
return
|
|
426
|
+
}
|
|
324
427
|
|
|
325
|
-
|
|
428
|
+
if (['sqlite', 'sqlite3'].includes(normalizedEngine)) {
|
|
429
|
+
await installEngineViaPackageManager('sqlite', 'SQLite')
|
|
430
|
+
return
|
|
431
|
+
}
|
|
326
432
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
platform,
|
|
334
|
-
arch,
|
|
335
|
-
})
|
|
336
|
-
console.log(chalk.gray(` Location: ${binPath}`))
|
|
433
|
+
console.error(
|
|
434
|
+
uiError(
|
|
435
|
+
`Unknown engine "${engineName}". Supported: postgresql, mysql, sqlite`,
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
process.exit(1)
|
|
337
439
|
} catch (error) {
|
|
338
440
|
const e = error as Error
|
|
339
441
|
console.error(uiError(e.message))
|
package/cli/ui/prompts.ts
CHANGED
|
@@ -608,18 +608,36 @@ export async function promptInstallDependencies(
|
|
|
608
608
|
const engineDeps = getEngineDependencies(engine)
|
|
609
609
|
const engineName = engineDeps?.displayName || engine
|
|
610
610
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
|
|
611
|
+
// In CI environments (no TTY or CI env var), auto-install without prompting
|
|
612
|
+
const isCI = !!(
|
|
613
|
+
process.env.CI ||
|
|
614
|
+
process.env.GITHUB_ACTIONS ||
|
|
615
|
+
!process.stdin.isTTY
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
let shouldInstall = 'yes'
|
|
619
|
+
|
|
620
|
+
if (!isCI) {
|
|
621
|
+
const response = await inquirer.prompt<{ shouldInstall: string }>([
|
|
622
|
+
{
|
|
623
|
+
type: 'list',
|
|
624
|
+
name: 'shouldInstall',
|
|
625
|
+
message: `Would you like to install ${engineName} client tools now?`,
|
|
626
|
+
choices: [
|
|
627
|
+
{ name: 'Yes, install now', value: 'yes' },
|
|
628
|
+
{ name: 'No, I will install manually', value: 'no' },
|
|
629
|
+
],
|
|
630
|
+
default: 'yes',
|
|
631
|
+
},
|
|
632
|
+
])
|
|
633
|
+
shouldInstall = response.shouldInstall
|
|
634
|
+
} else {
|
|
635
|
+
console.log(
|
|
636
|
+
chalk.gray(
|
|
637
|
+
` CI environment detected - auto-installing ${engineName} client tools...`,
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
}
|
|
623
641
|
|
|
624
642
|
if (shouldInstall === 'no') {
|
|
625
643
|
console.log()
|
package/cli/ui/theme.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Color theme for spindb CLI
|
|
5
|
-
*/
|
|
6
3
|
export const theme = {
|
|
7
4
|
// Brand colors
|
|
8
5
|
primary: chalk.cyan,
|
|
@@ -45,9 +42,6 @@ export const theme = {
|
|
|
45
42
|
},
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
/**
|
|
49
|
-
* Format a header box
|
|
50
|
-
*/
|
|
51
45
|
export function header(text: string): string {
|
|
52
46
|
const line = '─'.repeat(text.length + 4)
|
|
53
47
|
return `
|
|
@@ -57,61 +51,37 @@ ${chalk.cyan('└' + line + '┘')}
|
|
|
57
51
|
`.trim()
|
|
58
52
|
}
|
|
59
53
|
|
|
60
|
-
/**
|
|
61
|
-
* Format a success message
|
|
62
|
-
*/
|
|
63
54
|
export function uiSuccess(message: string): string {
|
|
64
55
|
return `${theme.icons.success} ${message}`
|
|
65
56
|
}
|
|
66
57
|
|
|
67
|
-
/**
|
|
68
|
-
* Format an error message
|
|
69
|
-
*/
|
|
70
58
|
export function uiError(message: string): string {
|
|
71
59
|
return `${theme.icons.error} ${chalk.red(message)}`
|
|
72
60
|
}
|
|
73
61
|
|
|
74
|
-
/**
|
|
75
|
-
* Format a warning message
|
|
76
|
-
*/
|
|
77
62
|
export function uiWarning(message: string): string {
|
|
78
63
|
return `${theme.icons.warning} ${chalk.yellow(message)}`
|
|
79
64
|
}
|
|
80
65
|
|
|
81
|
-
/**
|
|
82
|
-
* Format an info message
|
|
83
|
-
*/
|
|
84
66
|
export function uiInfo(message: string): string {
|
|
85
67
|
return `${theme.icons.info} ${message}`
|
|
86
68
|
}
|
|
87
69
|
|
|
88
|
-
/**
|
|
89
|
-
* Format a key-value pair
|
|
90
|
-
*/
|
|
91
70
|
export function keyValue(key: string, value: string): string {
|
|
92
71
|
return `${chalk.gray(key + ':')} ${value}`
|
|
93
72
|
}
|
|
94
73
|
|
|
95
|
-
/**
|
|
96
|
-
* Strip ANSI escape codes to get actual string length
|
|
97
|
-
*/
|
|
98
74
|
function stripAnsi(str: string): string {
|
|
99
75
|
// eslint-disable-next-line no-control-regex
|
|
100
76
|
return str.replace(/\x1B\[[0-9;]*m/g, '')
|
|
101
77
|
}
|
|
102
78
|
|
|
103
|
-
/**
|
|
104
|
-
* Pad a string (accounting for ANSI codes) to a specific visible width
|
|
105
|
-
*/
|
|
106
79
|
function padToWidth(str: string, width: number): string {
|
|
107
80
|
const visibleLength = stripAnsi(str).length
|
|
108
81
|
const padding = Math.max(0, width - visibleLength)
|
|
109
82
|
return str + ' '.repeat(padding)
|
|
110
83
|
}
|
|
111
84
|
|
|
112
|
-
/**
|
|
113
|
-
* Create a box with dynamic width based on content
|
|
114
|
-
*/
|
|
115
85
|
export function box(lines: string[], padding: number = 2): string {
|
|
116
86
|
// Calculate max visible width
|
|
117
87
|
const maxWidth = Math.max(...lines.map((line) => stripAnsi(line).length))
|
|
@@ -136,9 +106,6 @@ export function box(lines: string[], padding: number = 2): string {
|
|
|
136
106
|
return boxLines.join('\n')
|
|
137
107
|
}
|
|
138
108
|
|
|
139
|
-
/**
|
|
140
|
-
* Format a connection string box
|
|
141
|
-
*/
|
|
142
109
|
export function connectionBox(
|
|
143
110
|
name: string,
|
|
144
111
|
connectionString: string,
|
|
@@ -156,9 +123,6 @@ export function connectionBox(
|
|
|
156
123
|
return box(lines)
|
|
157
124
|
}
|
|
158
125
|
|
|
159
|
-
/**
|
|
160
|
-
* Format bytes into human-readable format (B, KB, MB, GB)
|
|
161
|
-
*/
|
|
162
126
|
export function formatBytes(bytes: number): string {
|
|
163
127
|
if (bytes === 0) return '0 B'
|
|
164
128
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
package/core/binary-manager.ts
CHANGED
|
@@ -399,20 +399,197 @@ export class BinaryManager {
|
|
|
399
399
|
onProgress?: ProgressCallback,
|
|
400
400
|
): Promise<string> {
|
|
401
401
|
const fullVersion = this.getFullVersion(version)
|
|
402
|
+
let binPath: string
|
|
403
|
+
|
|
402
404
|
if (await this.isInstalled(version, platform, arch)) {
|
|
403
405
|
onProgress?.({
|
|
404
406
|
stage: 'cached',
|
|
405
407
|
message: 'Using cached PostgreSQL binaries',
|
|
406
408
|
})
|
|
407
|
-
|
|
409
|
+
binPath = paths.getBinaryPath({
|
|
408
410
|
engine: 'postgresql',
|
|
409
411
|
version: fullVersion,
|
|
410
412
|
platform,
|
|
411
413
|
arch,
|
|
412
414
|
})
|
|
415
|
+
} else {
|
|
416
|
+
binPath = await this.download(version, platform, arch, onProgress)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// On Linux, zonky.io binaries don't include client tools (psql, pg_dump)
|
|
420
|
+
// Download them separately from the PostgreSQL apt repository
|
|
421
|
+
if (platform === 'linux') {
|
|
422
|
+
await this.ensureClientTools(binPath, version, onProgress)
|
|
413
423
|
}
|
|
414
424
|
|
|
415
|
-
return
|
|
425
|
+
return binPath
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Ensure PostgreSQL client tools are available on Linux
|
|
430
|
+
* Downloads from PostgreSQL apt repository if missing
|
|
431
|
+
*/
|
|
432
|
+
private async ensureClientTools(
|
|
433
|
+
binPath: string,
|
|
434
|
+
version: string,
|
|
435
|
+
onProgress?: ProgressCallback,
|
|
436
|
+
): Promise<void> {
|
|
437
|
+
const clientTools = ['psql', 'pg_dump', 'pg_restore', 'pg_dumpall']
|
|
438
|
+
const binDir = join(binPath, 'bin')
|
|
439
|
+
|
|
440
|
+
// Check if client tools already exist
|
|
441
|
+
const missingTools = clientTools.filter(
|
|
442
|
+
(tool) => !existsSync(join(binDir, tool)),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if (missingTools.length === 0) {
|
|
446
|
+
return // All client tools already present
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
onProgress?.({
|
|
450
|
+
stage: 'downloading',
|
|
451
|
+
message: 'Downloading PostgreSQL client tools...',
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const majorVersion = version.split('.')[0]
|
|
455
|
+
const tempDir = join(paths.bin, `temp-client-${majorVersion}`)
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
await mkdir(tempDir, { recursive: true })
|
|
459
|
+
|
|
460
|
+
// Get the latest client package version from apt repository
|
|
461
|
+
const debUrl = await this.getClientPackageUrl(majorVersion)
|
|
462
|
+
const debFile = join(tempDir, 'postgresql-client.deb')
|
|
463
|
+
|
|
464
|
+
// Download the .deb package
|
|
465
|
+
const response = await fetch(debUrl)
|
|
466
|
+
if (!response.ok) {
|
|
467
|
+
throw new Error(`Failed to download client tools: ${response.status}`)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const fileStream = createWriteStream(debFile)
|
|
471
|
+
// @ts-expect-error - response.body is ReadableStream
|
|
472
|
+
await pipeline(response.body, fileStream)
|
|
473
|
+
|
|
474
|
+
onProgress?.({
|
|
475
|
+
stage: 'extracting',
|
|
476
|
+
message: 'Extracting PostgreSQL client tools...',
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// Extract .deb using ar (available on Linux)
|
|
480
|
+
await execAsync(`ar -x "${debFile}"`, { cwd: tempDir })
|
|
481
|
+
|
|
482
|
+
// Find and extract data.tar.xz or data.tar.zst
|
|
483
|
+
const dataFile = await this.findDataTar(tempDir)
|
|
484
|
+
if (!dataFile) {
|
|
485
|
+
throw new Error('Could not find data archive in .deb package')
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Determine compression type and extract
|
|
489
|
+
const extractDir = join(tempDir, 'extracted')
|
|
490
|
+
await mkdir(extractDir, { recursive: true })
|
|
491
|
+
|
|
492
|
+
if (dataFile.endsWith('.xz')) {
|
|
493
|
+
await execAsync(`tar -xJf "${dataFile}" -C "${extractDir}"`)
|
|
494
|
+
} else if (dataFile.endsWith('.zst')) {
|
|
495
|
+
await execAsync(`tar --zstd -xf "${dataFile}" -C "${extractDir}"`)
|
|
496
|
+
} else if (dataFile.endsWith('.gz')) {
|
|
497
|
+
await execAsync(`tar -xzf "${dataFile}" -C "${extractDir}"`)
|
|
498
|
+
} else {
|
|
499
|
+
await execAsync(`tar -xf "${dataFile}" -C "${extractDir}"`)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Copy client tools to the bin directory
|
|
503
|
+
const clientBinDir = join(
|
|
504
|
+
extractDir,
|
|
505
|
+
'usr',
|
|
506
|
+
'lib',
|
|
507
|
+
'postgresql',
|
|
508
|
+
majorVersion,
|
|
509
|
+
'bin',
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if (existsSync(clientBinDir)) {
|
|
513
|
+
for (const tool of clientTools) {
|
|
514
|
+
const srcPath = join(clientBinDir, tool)
|
|
515
|
+
const destPath = join(binDir, tool)
|
|
516
|
+
if (existsSync(srcPath) && !existsSync(destPath)) {
|
|
517
|
+
await cp(srcPath, destPath)
|
|
518
|
+
await chmod(destPath, 0o755)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
onProgress?.({
|
|
524
|
+
stage: 'complete',
|
|
525
|
+
message: 'PostgreSQL client tools installed',
|
|
526
|
+
})
|
|
527
|
+
} finally {
|
|
528
|
+
// Clean up temp directory
|
|
529
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Get the download URL for PostgreSQL client package from apt repository
|
|
535
|
+
*/
|
|
536
|
+
private async getClientPackageUrl(majorVersion: string): Promise<string> {
|
|
537
|
+
const baseUrl = 'https://apt.postgresql.org/pub/repos/apt/pool/main/p'
|
|
538
|
+
const packageDir = `postgresql-${majorVersion}`
|
|
539
|
+
const indexUrl = `${baseUrl}/${packageDir}/`
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
const response = await fetch(indexUrl)
|
|
543
|
+
if (!response.ok) {
|
|
544
|
+
throw new Error(`Failed to fetch package index: ${response.status}`)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const html = await response.text()
|
|
548
|
+
|
|
549
|
+
// Find the latest postgresql-client package for amd64
|
|
550
|
+
// Pattern: postgresql-client-17_17.x.x-x.pgdg+1_amd64.deb
|
|
551
|
+
const pattern = new RegExp(
|
|
552
|
+
`href="(postgresql-client-${majorVersion}_[^"]+_amd64\\.deb)"`,
|
|
553
|
+
'g',
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const matches: string[] = []
|
|
557
|
+
let match
|
|
558
|
+
while ((match = pattern.exec(html)) !== null) {
|
|
559
|
+
// Skip debug symbols and snapshot versions
|
|
560
|
+
if (!match[1].includes('dbgsym') && !match[1].includes('~')) {
|
|
561
|
+
matches.push(match[1])
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (matches.length === 0) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
`No client package found for PostgreSQL ${majorVersion}`,
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Sort to get the latest version and return the URL
|
|
572
|
+
matches.sort().reverse()
|
|
573
|
+
const latestPackage = matches[0]
|
|
574
|
+
|
|
575
|
+
return `${indexUrl}${latestPackage}`
|
|
576
|
+
} catch (error) {
|
|
577
|
+
const err = error as Error
|
|
578
|
+
throw new Error(`Failed to get client package URL: ${err.message}`)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Find data.tar.* file in extracted .deb
|
|
584
|
+
*/
|
|
585
|
+
private async findDataTar(dir: string): Promise<string | null> {
|
|
586
|
+
const entries = await readdir(dir)
|
|
587
|
+
for (const entry of entries) {
|
|
588
|
+
if (entry.startsWith('data.tar')) {
|
|
589
|
+
return join(dir, entry)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null
|
|
416
593
|
}
|
|
417
594
|
}
|
|
418
595
|
|