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.
Files changed (41) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +112 -855
  3. package/cli/commands/connect.ts +99 -0
  4. package/cli/commands/create.ts +5 -1
  5. package/cli/commands/engines.ts +78 -1
  6. package/cli/commands/menu/backup-handlers.ts +9 -0
  7. package/cli/commands/menu/container-handlers.ts +41 -12
  8. package/cli/commands/menu/engine-handlers.ts +4 -0
  9. package/cli/commands/menu/index.ts +72 -1
  10. package/cli/commands/menu/settings-handlers.ts +3 -0
  11. package/cli/commands/menu/shell-handlers.ts +592 -12
  12. package/cli/commands/ports.ts +211 -0
  13. package/cli/constants.ts +7 -3
  14. package/cli/helpers.ts +73 -0
  15. package/cli/index.ts +2 -0
  16. package/cli/ui/prompts.ts +4 -2
  17. package/config/backup-formats.ts +14 -0
  18. package/config/engine-defaults.ts +13 -0
  19. package/config/engines.json +17 -0
  20. package/core/config-manager.ts +18 -0
  21. package/core/dblab-utils.ts +113 -0
  22. package/core/dependency-manager.ts +6 -0
  23. package/core/docker-exporter.ts +13 -0
  24. package/core/pgweb-utils.ts +62 -0
  25. package/engines/base-engine.ts +9 -0
  26. package/engines/cockroachdb/index.ts +3 -0
  27. package/engines/ferretdb/index.ts +46 -27
  28. package/engines/index.ts +4 -0
  29. package/engines/influxdb/README.md +180 -0
  30. package/engines/influxdb/api-client.ts +64 -0
  31. package/engines/influxdb/backup.ts +160 -0
  32. package/engines/influxdb/binary-manager.ts +110 -0
  33. package/engines/influxdb/binary-urls.ts +69 -0
  34. package/engines/influxdb/hostdb-releases.ts +23 -0
  35. package/engines/influxdb/index.ts +1227 -0
  36. package/engines/influxdb/restore.ts +417 -0
  37. package/engines/influxdb/version-maps.ts +75 -0
  38. package/engines/influxdb/version-validator.ts +128 -0
  39. package/engines/postgresql/index.ts +3 -0
  40. package/package.json +2 -1
  41. 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-Qdrant/Meilisearch/CouchDB engines: show default shell option
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 shell option:',
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`