spindb 0.32.2 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +112 -855
- package/cli/commands/connect.ts +99 -0
- package/cli/commands/create.ts +5 -1
- package/cli/commands/engines.ts +78 -1
- package/cli/commands/menu/backup-handlers.ts +9 -0
- package/cli/commands/menu/container-handlers.ts +41 -12
- package/cli/commands/menu/engine-handlers.ts +4 -0
- package/cli/commands/menu/index.ts +72 -1
- package/cli/commands/menu/settings-handlers.ts +3 -0
- package/cli/commands/menu/shell-handlers.ts +592 -12
- package/cli/commands/ports.ts +211 -0
- package/cli/constants.ts +7 -3
- package/cli/helpers.ts +73 -0
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +4 -2
- package/config/backup-formats.ts +14 -0
- package/config/engine-defaults.ts +13 -0
- package/config/engines.json +17 -0
- package/core/config-manager.ts +18 -0
- package/core/dblab-utils.ts +113 -0
- package/core/dependency-manager.ts +6 -0
- package/core/docker-exporter.ts +13 -0
- package/core/pgweb-utils.ts +62 -0
- package/engines/base-engine.ts +9 -0
- package/engines/cockroachdb/index.ts +3 -0
- package/engines/ferretdb/index.ts +46 -27
- package/engines/index.ts +4 -0
- package/engines/influxdb/README.md +180 -0
- package/engines/influxdb/api-client.ts +64 -0
- package/engines/influxdb/backup.ts +160 -0
- package/engines/influxdb/binary-manager.ts +110 -0
- package/engines/influxdb/binary-urls.ts +69 -0
- package/engines/influxdb/hostdb-releases.ts +23 -0
- package/engines/influxdb/index.ts +1227 -0
- package/engines/influxdb/restore.ts +417 -0
- package/engines/influxdb/version-maps.ts +75 -0
- package/engines/influxdb/version-validator.ts +128 -0
- package/engines/postgresql/index.ts +3 -0
- package/package.json +2 -1
- package/types/index.ts +17 -0
|
@@ -4,7 +4,7 @@ import { spawn } from 'child_process'
|
|
|
4
4
|
import { escapeablePrompt } from '../../ui/prompts'
|
|
5
5
|
import { getPageSize } from '../../constants'
|
|
6
6
|
import { existsSync } from 'fs'
|
|
7
|
-
import { mkdir, writeFile, rm } from 'fs/promises'
|
|
7
|
+
import { chmod, mkdir, writeFile, rm } from 'fs/promises'
|
|
8
8
|
import { join, dirname, resolve, sep } from 'path'
|
|
9
9
|
import { containerManager } from '../../../core/container-manager'
|
|
10
10
|
import {
|
|
@@ -26,7 +26,19 @@ import {
|
|
|
26
26
|
getIredisManualInstructions,
|
|
27
27
|
} from '../../../core/dependency-manager'
|
|
28
28
|
import { platformService } from '../../../core/platform-service'
|
|
29
|
+
import { portManager } from '../../../core/port-manager'
|
|
29
30
|
import { configManager } from '../../../core/config-manager'
|
|
31
|
+
import {
|
|
32
|
+
getPgwebStatus,
|
|
33
|
+
stopPgweb,
|
|
34
|
+
PGWEB_VERSION,
|
|
35
|
+
} from '../../../core/pgweb-utils'
|
|
36
|
+
import {
|
|
37
|
+
DBLAB_ENGINES,
|
|
38
|
+
DBLAB_VERSION,
|
|
39
|
+
getDblabArgs,
|
|
40
|
+
getDblabPlatformSuffix,
|
|
41
|
+
} from '../../../core/dblab-utils'
|
|
30
42
|
import { getEngine } from '../../../engines'
|
|
31
43
|
import { createSpinner } from '../../ui/spinner'
|
|
32
44
|
import { uiError, uiWarning, uiInfo, uiSuccess } from '../../ui/theme'
|
|
@@ -134,6 +146,12 @@ export async function handleOpenShell(
|
|
|
134
146
|
| 'browser'
|
|
135
147
|
| 'api-info'
|
|
136
148
|
| 'install-webui'
|
|
149
|
+
| 'pgweb'
|
|
150
|
+
| 'install-pgweb'
|
|
151
|
+
| 'stop-pgweb'
|
|
152
|
+
| 'dblab'
|
|
153
|
+
| 'install-dblab'
|
|
154
|
+
| 'duckdb-ui'
|
|
137
155
|
| 'usql'
|
|
138
156
|
| 'install-usql'
|
|
139
157
|
| 'pgcli'
|
|
@@ -218,6 +236,13 @@ export async function handleOpenShell(
|
|
|
218
236
|
engineSpecificInstalled = false
|
|
219
237
|
engineSpecificValue = null
|
|
220
238
|
engineSpecificInstallValue = null
|
|
239
|
+
} else if (config.engine === 'influxdb') {
|
|
240
|
+
// InfluxDB uses REST API, no interactive shell
|
|
241
|
+
defaultShellName = 'Web Dashboard'
|
|
242
|
+
engineSpecificCli = null
|
|
243
|
+
engineSpecificInstalled = false
|
|
244
|
+
engineSpecificValue = null
|
|
245
|
+
engineSpecificInstallValue = null
|
|
221
246
|
} else if (config.engine === 'couchdb') {
|
|
222
247
|
// CouchDB uses REST API, open Fauxton dashboard in browser
|
|
223
248
|
defaultShellName = 'Fauxton Dashboard'
|
|
@@ -308,6 +333,12 @@ export async function handleOpenShell(
|
|
|
308
333
|
name: `ℹ Show API info`,
|
|
309
334
|
value: 'api-info',
|
|
310
335
|
})
|
|
336
|
+
} else if (config.engine === 'influxdb') {
|
|
337
|
+
// InfluxDB: REST API only, no web dashboard or interactive shell
|
|
338
|
+
choices.push({
|
|
339
|
+
name: `ℹ Show API info`,
|
|
340
|
+
value: 'api-info',
|
|
341
|
+
})
|
|
311
342
|
} else if (config.engine === 'couchdb') {
|
|
312
343
|
// CouchDB: Fauxton dashboard is built-in at /_utils
|
|
313
344
|
choices.push({
|
|
@@ -320,22 +351,13 @@ export async function handleOpenShell(
|
|
|
320
351
|
value: 'api-info',
|
|
321
352
|
})
|
|
322
353
|
} else {
|
|
323
|
-
// Non-
|
|
354
|
+
// Non-REST-API engines: show default shell option
|
|
324
355
|
choices.push({
|
|
325
356
|
name: `>_ Use default shell (${defaultShellName})`,
|
|
326
357
|
value: 'default',
|
|
327
358
|
})
|
|
328
359
|
}
|
|
329
360
|
|
|
330
|
-
// Add browser option for ClickHouse (Play UI on HTTP port = native port + 1)
|
|
331
|
-
if (config.engine === 'clickhouse') {
|
|
332
|
-
const httpPort = config.port + 1
|
|
333
|
-
choices.push({
|
|
334
|
-
name: `◎ Open Play UI in browser (port ${httpPort})`,
|
|
335
|
-
value: 'browser',
|
|
336
|
-
})
|
|
337
|
-
}
|
|
338
|
-
|
|
339
361
|
// Only show engine-specific CLI option if one exists (MongoDB's mongosh IS the default)
|
|
340
362
|
if (engineSpecificCli !== null) {
|
|
341
363
|
if (engineSpecificInstalled) {
|
|
@@ -367,6 +389,72 @@ export async function handleOpenShell(
|
|
|
367
389
|
}
|
|
368
390
|
}
|
|
369
391
|
|
|
392
|
+
// dblab visual TUI (supports PostgreSQL, MySQL, MariaDB, CockroachDB, SQLite, QuestDB)
|
|
393
|
+
if (DBLAB_ENGINES.has(config.engine)) {
|
|
394
|
+
const dblabPath = await configManager.getBinaryPath('dblab')
|
|
395
|
+
if (dblabPath) {
|
|
396
|
+
choices.push({
|
|
397
|
+
name: '⚡ Use dblab (visual TUI)',
|
|
398
|
+
value: 'dblab',
|
|
399
|
+
})
|
|
400
|
+
} else {
|
|
401
|
+
choices.push({
|
|
402
|
+
name: '↓ Download dblab (visual TUI)',
|
|
403
|
+
value: 'install-dblab',
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Web Panel section for engines with browser-based UIs
|
|
409
|
+
if (config.engine === 'clickhouse') {
|
|
410
|
+
const httpPort = config.port + 1
|
|
411
|
+
choices.push(new inquirer.Separator(chalk.gray(`───── Web Panel ─────`)))
|
|
412
|
+
choices.push({
|
|
413
|
+
name: `◎ Open Play UI (port ${httpPort})`,
|
|
414
|
+
value: 'browser',
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (config.engine === 'duckdb') {
|
|
419
|
+
choices.push(new inquirer.Separator(chalk.gray(`───── Web Panel ─────`)))
|
|
420
|
+
choices.push({
|
|
421
|
+
name: `◎ Open Web UI (built-in, port 4213)`,
|
|
422
|
+
value: 'duckdb-ui',
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (
|
|
427
|
+
config.engine === 'postgresql' ||
|
|
428
|
+
config.engine === 'cockroachdb' ||
|
|
429
|
+
config.engine === 'ferretdb'
|
|
430
|
+
) {
|
|
431
|
+
choices.push(new inquirer.Separator(chalk.gray(`───── Web Panel ─────`)))
|
|
432
|
+
const pgwebPath = await configManager.getBinaryPath('pgweb')
|
|
433
|
+
if (pgwebPath) {
|
|
434
|
+
const pgwebStatus = await getPgwebStatus(containerName, config.engine)
|
|
435
|
+
if (pgwebStatus.running) {
|
|
436
|
+
choices.push({
|
|
437
|
+
name: `◎ Open pgweb (port ${pgwebStatus.port})`,
|
|
438
|
+
value: 'pgweb',
|
|
439
|
+
})
|
|
440
|
+
choices.push({
|
|
441
|
+
name: `■ Stop pgweb`,
|
|
442
|
+
value: 'stop-pgweb',
|
|
443
|
+
})
|
|
444
|
+
} else {
|
|
445
|
+
choices.push({
|
|
446
|
+
name: `◎ Open pgweb`,
|
|
447
|
+
value: 'pgweb',
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
choices.push({
|
|
452
|
+
name: `↓ Download pgweb`,
|
|
453
|
+
value: 'install-pgweb',
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
370
458
|
choices.push(new inquirer.Separator())
|
|
371
459
|
choices.push({
|
|
372
460
|
name: `${chalk.blue('←')} Back`,
|
|
@@ -377,7 +465,7 @@ export async function handleOpenShell(
|
|
|
377
465
|
{
|
|
378
466
|
type: 'list',
|
|
379
467
|
name: 'shellChoice',
|
|
380
|
-
message: 'Select
|
|
468
|
+
message: 'Select console option:',
|
|
381
469
|
choices,
|
|
382
470
|
pageSize: getPageSize(),
|
|
383
471
|
},
|
|
@@ -403,6 +491,56 @@ export async function handleOpenShell(
|
|
|
403
491
|
return
|
|
404
492
|
}
|
|
405
493
|
|
|
494
|
+
// Handle DuckDB built-in Web UI (duckdb -ui)
|
|
495
|
+
if (shellChoice === 'duckdb-ui') {
|
|
496
|
+
const duckdbPath = await configManager.getBinaryPath('duckdb')
|
|
497
|
+
if (!duckdbPath) {
|
|
498
|
+
console.error(
|
|
499
|
+
uiError(
|
|
500
|
+
'DuckDB binary not found. Download it with: spindb engines download duckdb',
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
await pressEnterToContinue()
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
console.log()
|
|
508
|
+
console.log(uiInfo('Launching DuckDB Web UI...'))
|
|
509
|
+
console.log(chalk.gray(' http://localhost:4213'))
|
|
510
|
+
console.log()
|
|
511
|
+
|
|
512
|
+
const uiProcess = spawn(duckdbPath, [config.database, '-ui'], {
|
|
513
|
+
stdio: 'inherit',
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
await new Promise<void>((resolve) => {
|
|
517
|
+
let settled = false
|
|
518
|
+
const settle = () => {
|
|
519
|
+
if (!settled) {
|
|
520
|
+
settled = true
|
|
521
|
+
resolve()
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
uiProcess.on('error', (err: NodeJS.ErrnoException) => {
|
|
526
|
+
if (err.code === 'ENOENT') {
|
|
527
|
+
console.log(uiWarning('DuckDB binary not found.'))
|
|
528
|
+
} else {
|
|
529
|
+
console.log(uiError(`Failed to start DuckDB UI: ${err.message}`))
|
|
530
|
+
}
|
|
531
|
+
settle()
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
uiProcess.on('close', () => {
|
|
535
|
+
if (process.stdout.isTTY) {
|
|
536
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
|
|
537
|
+
}
|
|
538
|
+
settle()
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
|
|
406
544
|
// Handle Qdrant/Meilisearch API info display
|
|
407
545
|
if (shellChoice === 'api-info') {
|
|
408
546
|
console.log()
|
|
@@ -424,6 +562,17 @@ export async function handleOpenShell(
|
|
|
424
562
|
console.log(chalk.gray(` curl http://127.0.0.1:${config.port}/indexes`))
|
|
425
563
|
console.log(chalk.gray(` curl http://127.0.0.1:${config.port}/health`))
|
|
426
564
|
console.log(chalk.gray(` curl http://127.0.0.1:${config.port}/stats`))
|
|
565
|
+
} else if (config.engine === 'influxdb') {
|
|
566
|
+
console.log(chalk.cyan('InfluxDB REST API:'))
|
|
567
|
+
console.log(chalk.white(` HTTP: http://127.0.0.1:${config.port}`))
|
|
568
|
+
console.log()
|
|
569
|
+
console.log(chalk.gray('Example curl commands:'))
|
|
570
|
+
console.log(chalk.gray(` curl http://127.0.0.1:${config.port}/health`))
|
|
571
|
+
console.log(
|
|
572
|
+
chalk.gray(
|
|
573
|
+
` curl -H "Content-Type: application/json" http://127.0.0.1:${config.port}/api/v3/query_sql -d '{"db":"mydb","q":"SELECT 1"}'`,
|
|
574
|
+
),
|
|
575
|
+
)
|
|
427
576
|
} else if (config.engine === 'couchdb') {
|
|
428
577
|
console.log(chalk.cyan('CouchDB REST API:'))
|
|
429
578
|
console.log(chalk.white(` HTTP: http://127.0.0.1:${config.port}`))
|
|
@@ -646,6 +795,42 @@ export async function handleOpenShell(
|
|
|
646
795
|
return
|
|
647
796
|
}
|
|
648
797
|
|
|
798
|
+
// Handle dblab download → launch immediately after install
|
|
799
|
+
if (shellChoice === 'install-dblab') {
|
|
800
|
+
const dblabBinaryPath = await downloadDblabCli()
|
|
801
|
+
if (dblabBinaryPath) {
|
|
802
|
+
await launchDblab(config, activeDatabase)
|
|
803
|
+
}
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Handle dblab launch
|
|
808
|
+
if (shellChoice === 'dblab') {
|
|
809
|
+
await launchDblab(config, activeDatabase)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Handle pgweb download → launch immediately after install
|
|
814
|
+
if (shellChoice === 'install-pgweb') {
|
|
815
|
+
const pgwebBinaryPath = await downloadPgweb()
|
|
816
|
+
if (pgwebBinaryPath) {
|
|
817
|
+
await launchPgweb(containerName, config, activeDatabase)
|
|
818
|
+
}
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Handle pgweb launch
|
|
823
|
+
if (shellChoice === 'pgweb') {
|
|
824
|
+
await launchPgweb(containerName, config, activeDatabase)
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Handle pgweb stop
|
|
829
|
+
if (shellChoice === 'stop-pgweb') {
|
|
830
|
+
await stopPgwebProcess(containerName, config.engine)
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
|
|
649
834
|
// Handle install-webui option for Qdrant
|
|
650
835
|
if (shellChoice === 'install-webui') {
|
|
651
836
|
if (config.engine === 'qdrant') {
|
|
@@ -785,6 +970,383 @@ async function downloadQdrantWebUI(containerName: string): Promise<void> {
|
|
|
785
970
|
await pressEnterToContinue()
|
|
786
971
|
}
|
|
787
972
|
|
|
973
|
+
/**
|
|
974
|
+
* Stop a running pgweb process for a container (with UI feedback)
|
|
975
|
+
*/
|
|
976
|
+
export async function stopPgwebProcess(
|
|
977
|
+
containerName: string,
|
|
978
|
+
engine: string,
|
|
979
|
+
): Promise<void> {
|
|
980
|
+
const stopped = await stopPgweb(containerName, engine)
|
|
981
|
+
|
|
982
|
+
console.log()
|
|
983
|
+
if (stopped) {
|
|
984
|
+
console.log(uiSuccess('pgweb stopped'))
|
|
985
|
+
} else {
|
|
986
|
+
console.log(uiInfo('pgweb is not running'))
|
|
987
|
+
}
|
|
988
|
+
console.log()
|
|
989
|
+
await pressEnterToContinue()
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Download and install pgweb from GitHub releases
|
|
994
|
+
*/
|
|
995
|
+
async function downloadPgweb(): Promise<string | null> {
|
|
996
|
+
console.log()
|
|
997
|
+
const spinner = createSpinner('Downloading pgweb...')
|
|
998
|
+
spinner.start()
|
|
999
|
+
|
|
1000
|
+
try {
|
|
1001
|
+
const platform = process.platform
|
|
1002
|
+
const arch = process.arch
|
|
1003
|
+
let suffix: string
|
|
1004
|
+
|
|
1005
|
+
if (platform === 'darwin' && arch === 'arm64') {
|
|
1006
|
+
suffix = 'darwin_arm64'
|
|
1007
|
+
} else if (platform === 'darwin' && arch === 'x64') {
|
|
1008
|
+
suffix = 'darwin_amd64'
|
|
1009
|
+
} else if (platform === 'linux' && arch === 'arm64') {
|
|
1010
|
+
suffix = 'linux_arm64'
|
|
1011
|
+
} else if (platform === 'linux' && arch === 'x64') {
|
|
1012
|
+
suffix = 'linux_amd64'
|
|
1013
|
+
} else if (platform === 'win32' && arch === 'x64') {
|
|
1014
|
+
suffix = 'windows_amd64.exe'
|
|
1015
|
+
} else {
|
|
1016
|
+
throw new Error(`Unsupported platform: ${platform} ${arch}`)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const zipUrl = `https://github.com/sosedoff/pgweb/releases/download/v${PGWEB_VERSION}/pgweb_${suffix}.zip`
|
|
1020
|
+
|
|
1021
|
+
spinner.text = `Downloading pgweb v${PGWEB_VERSION}...`
|
|
1022
|
+
|
|
1023
|
+
const response = await fetch(zipUrl)
|
|
1024
|
+
if (!response.ok || !response.body) {
|
|
1025
|
+
throw new Error(`Failed to download: ${response.status}`)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const isWin = platform === 'win32'
|
|
1029
|
+
const binaryName = isWin ? 'pgweb.exe' : 'pgweb'
|
|
1030
|
+
const platformArch = `${platform}-${arch}`
|
|
1031
|
+
const installDir = join(
|
|
1032
|
+
paths.bin,
|
|
1033
|
+
`pgweb-${PGWEB_VERSION}-${platformArch}`,
|
|
1034
|
+
'bin',
|
|
1035
|
+
)
|
|
1036
|
+
await mkdir(installDir, { recursive: true })
|
|
1037
|
+
|
|
1038
|
+
const tempZip = join(paths.bin, 'pgweb-temp.zip')
|
|
1039
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
1040
|
+
await writeFile(tempZip, buffer)
|
|
1041
|
+
|
|
1042
|
+
spinner.text = 'Extracting pgweb...'
|
|
1043
|
+
|
|
1044
|
+
try {
|
|
1045
|
+
const unzipper = await import('unzipper')
|
|
1046
|
+
const directory = await unzipper.Open.file(tempZip)
|
|
1047
|
+
|
|
1048
|
+
const resolvedInstallDir = resolve(installDir)
|
|
1049
|
+
let extracted = false
|
|
1050
|
+
|
|
1051
|
+
for (const entry of directory.files) {
|
|
1052
|
+
if (entry.type === 'Directory') continue
|
|
1053
|
+
|
|
1054
|
+
// Zip-slip protection
|
|
1055
|
+
const targetPath = resolve(installDir, binaryName)
|
|
1056
|
+
if (!targetPath.startsWith(resolvedInstallDir + sep)) {
|
|
1057
|
+
continue
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// The zip contains the binary (possibly named pgweb_<platform>_<arch> or pgweb_<platform>_<arch>.exe)
|
|
1061
|
+
const content = await entry.buffer()
|
|
1062
|
+
await writeFile(targetPath, content)
|
|
1063
|
+
extracted = true
|
|
1064
|
+
break // Only one file in the zip
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (!extracted) {
|
|
1068
|
+
throw new Error('Could not find pgweb binary in zip archive')
|
|
1069
|
+
}
|
|
1070
|
+
} finally {
|
|
1071
|
+
await rm(tempZip, { force: true })
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const binaryPath = join(installDir, binaryName)
|
|
1075
|
+
|
|
1076
|
+
// chmod on Unix
|
|
1077
|
+
if (!isWin) {
|
|
1078
|
+
await chmod(binaryPath, 0o755)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Register in config
|
|
1082
|
+
await configManager.setBinaryPath('pgweb', binaryPath, 'bundled')
|
|
1083
|
+
|
|
1084
|
+
spinner.succeed(`pgweb v${PGWEB_VERSION} installed`)
|
|
1085
|
+
console.log()
|
|
1086
|
+
|
|
1087
|
+
return binaryPath
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
spinner.fail('Failed to download pgweb')
|
|
1090
|
+
console.error(uiError((error as Error).message))
|
|
1091
|
+
console.log()
|
|
1092
|
+
console.log(chalk.gray('You can manually download from:'))
|
|
1093
|
+
console.log(chalk.cyan(' https://github.com/sosedoff/pgweb/releases'))
|
|
1094
|
+
console.log()
|
|
1095
|
+
await pressEnterToContinue()
|
|
1096
|
+
return null
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Download and install dblab from GitHub releases.
|
|
1102
|
+
* Exported as downloadDblabCli for use from the CLI connect command.
|
|
1103
|
+
*/
|
|
1104
|
+
export async function downloadDblabCli(): Promise<string | null> {
|
|
1105
|
+
console.log()
|
|
1106
|
+
const spinner = createSpinner('Downloading dblab...')
|
|
1107
|
+
spinner.start()
|
|
1108
|
+
|
|
1109
|
+
try {
|
|
1110
|
+
const suffix = getDblabPlatformSuffix()
|
|
1111
|
+
const tarUrl = `https://github.com/danvergara/dblab/releases/download/v${DBLAB_VERSION}/dblab_${DBLAB_VERSION}_${suffix}.tar.gz`
|
|
1112
|
+
|
|
1113
|
+
spinner.text = `Downloading dblab v${DBLAB_VERSION}...`
|
|
1114
|
+
|
|
1115
|
+
const response = await fetch(tarUrl)
|
|
1116
|
+
if (!response.ok || !response.body) {
|
|
1117
|
+
throw new Error(`Failed to download: ${response.status}`)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const isWin = process.platform === 'win32'
|
|
1121
|
+
const binaryName = isWin ? 'dblab.exe' : 'dblab'
|
|
1122
|
+
const platformArch = `${process.platform}-${process.arch}`
|
|
1123
|
+
const installDir = join(
|
|
1124
|
+
paths.bin,
|
|
1125
|
+
`dblab-${DBLAB_VERSION}-${platformArch}`,
|
|
1126
|
+
'bin',
|
|
1127
|
+
)
|
|
1128
|
+
await mkdir(installDir, { recursive: true })
|
|
1129
|
+
|
|
1130
|
+
const tempTar = join(paths.bin, 'dblab-temp.tar.gz')
|
|
1131
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
1132
|
+
await writeFile(tempTar, buffer)
|
|
1133
|
+
|
|
1134
|
+
spinner.text = 'Extracting dblab...'
|
|
1135
|
+
|
|
1136
|
+
try {
|
|
1137
|
+
const { spawnSync } = await import('child_process')
|
|
1138
|
+
const result = spawnSync('tar', ['-xzf', tempTar, '-C', installDir], {
|
|
1139
|
+
stdio: 'pipe',
|
|
1140
|
+
})
|
|
1141
|
+
if (result.status !== 0) {
|
|
1142
|
+
throw new Error(
|
|
1143
|
+
`tar extraction failed: ${result.stderr?.toString() || 'unknown error'}`,
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1146
|
+
} finally {
|
|
1147
|
+
await rm(tempTar, { force: true })
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const binaryPath = join(installDir, binaryName)
|
|
1151
|
+
|
|
1152
|
+
if (!existsSync(binaryPath)) {
|
|
1153
|
+
throw new Error('Could not find dblab binary after extraction')
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// chmod on Unix
|
|
1157
|
+
if (!isWin) {
|
|
1158
|
+
await chmod(binaryPath, 0o755)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Register in config
|
|
1162
|
+
await configManager.setBinaryPath('dblab', binaryPath, 'bundled')
|
|
1163
|
+
|
|
1164
|
+
spinner.succeed(`dblab v${DBLAB_VERSION} installed`)
|
|
1165
|
+
console.log()
|
|
1166
|
+
|
|
1167
|
+
return binaryPath
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
spinner.fail('Failed to download dblab')
|
|
1170
|
+
console.error(uiError((error as Error).message))
|
|
1171
|
+
console.log()
|
|
1172
|
+
console.log(chalk.gray('You can manually download from:'))
|
|
1173
|
+
console.log(chalk.cyan(' https://github.com/danvergara/dblab/releases'))
|
|
1174
|
+
console.log()
|
|
1175
|
+
await pressEnterToContinue()
|
|
1176
|
+
return null
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Launch dblab visual TUI for a container
|
|
1182
|
+
*/
|
|
1183
|
+
async function launchDblab(
|
|
1184
|
+
config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>,
|
|
1185
|
+
database: string,
|
|
1186
|
+
): Promise<void> {
|
|
1187
|
+
const dblabPath = await configManager.getBinaryPath('dblab')
|
|
1188
|
+
if (!dblabPath) {
|
|
1189
|
+
console.error(uiError('dblab not found. Download it first.'))
|
|
1190
|
+
await pressEnterToContinue()
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const args = getDblabArgs(config, database)
|
|
1195
|
+
|
|
1196
|
+
console.log()
|
|
1197
|
+
console.log(chalk.gray(' dblab keybindings:'))
|
|
1198
|
+
console.log(
|
|
1199
|
+
chalk.gray(
|
|
1200
|
+
' Ctrl+Space: run query | Ctrl+H/J/K/L: navigate panels | Ctrl+S: structure view',
|
|
1201
|
+
),
|
|
1202
|
+
)
|
|
1203
|
+
console.log()
|
|
1204
|
+
await escapeablePrompt([
|
|
1205
|
+
{
|
|
1206
|
+
type: 'input',
|
|
1207
|
+
name: 'continue',
|
|
1208
|
+
message: chalk.gray('Press Enter to launch dblab...'),
|
|
1209
|
+
},
|
|
1210
|
+
])
|
|
1211
|
+
|
|
1212
|
+
const dblabProcess = spawn(dblabPath, args, {
|
|
1213
|
+
stdio: 'inherit',
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
await new Promise<void>((resolve) => {
|
|
1217
|
+
let settled = false
|
|
1218
|
+
|
|
1219
|
+
const settle = () => {
|
|
1220
|
+
if (!settled) {
|
|
1221
|
+
settled = true
|
|
1222
|
+
resolve()
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
dblabProcess.on('error', (err: NodeJS.ErrnoException) => {
|
|
1227
|
+
if (err.code === 'ENOENT') {
|
|
1228
|
+
console.log(uiWarning('dblab not found on your system.'))
|
|
1229
|
+
console.log()
|
|
1230
|
+
console.log(chalk.gray(' Download it with:'))
|
|
1231
|
+
console.log(chalk.cyan(' spindb connect --install-dblab'))
|
|
1232
|
+
} else {
|
|
1233
|
+
console.log(uiError(`Failed to start dblab: ${err.message}`))
|
|
1234
|
+
}
|
|
1235
|
+
settle()
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
dblabProcess.on('close', () => {
|
|
1239
|
+
if (process.stdout.isTTY) {
|
|
1240
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
|
|
1241
|
+
}
|
|
1242
|
+
settle()
|
|
1243
|
+
})
|
|
1244
|
+
})
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Launch pgweb for a PostgreSQL-compatible container
|
|
1249
|
+
*/
|
|
1250
|
+
async function launchPgweb(
|
|
1251
|
+
containerName: string,
|
|
1252
|
+
config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>,
|
|
1253
|
+
database: string,
|
|
1254
|
+
): Promise<void> {
|
|
1255
|
+
const pgwebPath = await configManager.getBinaryPath('pgweb')
|
|
1256
|
+
if (!pgwebPath) {
|
|
1257
|
+
console.error(uiError('pgweb not found. Download it first.'))
|
|
1258
|
+
await pressEnterToContinue()
|
|
1259
|
+
return
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const containerDir = paths.getContainerPath(containerName, {
|
|
1263
|
+
engine: config.engine,
|
|
1264
|
+
})
|
|
1265
|
+
const pidFile = join(containerDir, 'pgweb.pid')
|
|
1266
|
+
const portFile = join(containerDir, 'pgweb.port')
|
|
1267
|
+
|
|
1268
|
+
// Check if already running — just open browser
|
|
1269
|
+
const status = await getPgwebStatus(containerName, config.engine)
|
|
1270
|
+
if (status.running && status.port) {
|
|
1271
|
+
const url = `http://127.0.0.1:${status.port}`
|
|
1272
|
+
console.log()
|
|
1273
|
+
console.log(uiInfo(`Opening pgweb`))
|
|
1274
|
+
console.log(chalk.gray(` ${url}`))
|
|
1275
|
+
console.log()
|
|
1276
|
+
openInBrowser(url)
|
|
1277
|
+
await pressEnterToContinue()
|
|
1278
|
+
return
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Find available port starting at 8081
|
|
1282
|
+
let port = 8081
|
|
1283
|
+
while (!(await portManager.isPortAvailable(port)) && port < 8200) {
|
|
1284
|
+
port++
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (port >= 8200) {
|
|
1288
|
+
console.error(
|
|
1289
|
+
uiError(
|
|
1290
|
+
'Could not find an available port for pgweb (scanned 8081–8199). ' +
|
|
1291
|
+
'Check for other pgweb or server processes using those ports.',
|
|
1292
|
+
),
|
|
1293
|
+
)
|
|
1294
|
+
await pressEnterToContinue()
|
|
1295
|
+
return
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Build connection URL
|
|
1299
|
+
let connectionUrl: string
|
|
1300
|
+
if (config.engine === 'ferretdb') {
|
|
1301
|
+
// FerretDB has a PostgreSQL backend on backendPort — always connects to 'ferretdb' database
|
|
1302
|
+
if (!config.backendPort) {
|
|
1303
|
+
console.log()
|
|
1304
|
+
console.error(
|
|
1305
|
+
uiError(
|
|
1306
|
+
'PostgreSQL backend port not set — restart the container first',
|
|
1307
|
+
),
|
|
1308
|
+
)
|
|
1309
|
+
console.log()
|
|
1310
|
+
await pressEnterToContinue()
|
|
1311
|
+
return
|
|
1312
|
+
}
|
|
1313
|
+
connectionUrl = `postgresql://postgres@127.0.0.1:${config.backendPort}/ferretdb?sslmode=disable`
|
|
1314
|
+
} else if (config.engine === 'cockroachdb') {
|
|
1315
|
+
connectionUrl = `postgresql://root@127.0.0.1:${config.port}/${database}?sslmode=disable`
|
|
1316
|
+
} else {
|
|
1317
|
+
connectionUrl = `postgresql://postgres@127.0.0.1:${config.port}/${database}?sslmode=disable`
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Spawn pgweb detached
|
|
1321
|
+
const pgwebProcess = spawn(
|
|
1322
|
+
pgwebPath,
|
|
1323
|
+
['--url', connectionUrl, '--bind', '127.0.0.1', '--listen', String(port)],
|
|
1324
|
+
{
|
|
1325
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1326
|
+
detached: true,
|
|
1327
|
+
},
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
pgwebProcess.unref()
|
|
1331
|
+
|
|
1332
|
+
// Write PID and port files
|
|
1333
|
+
if (pgwebProcess.pid) {
|
|
1334
|
+
await writeFile(pidFile, String(pgwebProcess.pid))
|
|
1335
|
+
await writeFile(portFile, String(port))
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Brief wait for startup
|
|
1339
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
1340
|
+
|
|
1341
|
+
const url = `http://127.0.0.1:${port}`
|
|
1342
|
+
console.log()
|
|
1343
|
+
console.log(uiSuccess(`pgweb started on ${url}`))
|
|
1344
|
+
console.log(chalk.gray(` PID: ${pgwebProcess.pid}`))
|
|
1345
|
+
console.log()
|
|
1346
|
+
openInBrowser(url)
|
|
1347
|
+
await pressEnterToContinue()
|
|
1348
|
+
}
|
|
1349
|
+
|
|
788
1350
|
async function launchShell(
|
|
789
1351
|
containerName: string,
|
|
790
1352
|
config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>,
|
|
@@ -919,6 +1481,24 @@ async function launchShell(
|
|
|
919
1481
|
openInBrowser(dashboardUrl)
|
|
920
1482
|
await pressEnterToContinue()
|
|
921
1483
|
return
|
|
1484
|
+
} else if (config.engine === 'influxdb') {
|
|
1485
|
+
// InfluxDB: REST API only, no web dashboard
|
|
1486
|
+
// This branch shouldn't be reached since we removed the 'default' choice,
|
|
1487
|
+
// but handle gracefully just in case
|
|
1488
|
+
console.log()
|
|
1489
|
+
console.log(chalk.cyan('InfluxDB REST API:'))
|
|
1490
|
+
console.log(chalk.white(` HTTP: http://127.0.0.1:${config.port}`))
|
|
1491
|
+
console.log()
|
|
1492
|
+
console.log(chalk.gray('Example curl commands:'))
|
|
1493
|
+
console.log(chalk.gray(` curl http://127.0.0.1:${config.port}/health`))
|
|
1494
|
+
console.log(
|
|
1495
|
+
chalk.gray(
|
|
1496
|
+
` curl -H "Content-Type: application/json" http://127.0.0.1:${config.port}/api/v3/query_sql -d '{"db":"mydb","q":"SELECT 1"}'`,
|
|
1497
|
+
),
|
|
1498
|
+
)
|
|
1499
|
+
console.log()
|
|
1500
|
+
await pressEnterToContinue()
|
|
1501
|
+
return
|
|
922
1502
|
} else if (config.engine === 'couchdb') {
|
|
923
1503
|
// CouchDB: Open Fauxton dashboard in browser (served at /_utils)
|
|
924
1504
|
const dashboardUrl = `http://127.0.0.1:${config.port}/_utils`
|