spindb 0.33.1 → 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.
@@ -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'
@@ -340,15 +358,6 @@ export async function handleOpenShell(
340
358
  })
341
359
  }
342
360
 
343
- // Add browser option for ClickHouse (Play UI on HTTP port = native port + 1)
344
- if (config.engine === 'clickhouse') {
345
- const httpPort = config.port + 1
346
- choices.push({
347
- name: `◎ Open Play UI in browser (port ${httpPort})`,
348
- value: 'browser',
349
- })
350
- }
351
-
352
361
  // Only show engine-specific CLI option if one exists (MongoDB's mongosh IS the default)
353
362
  if (engineSpecificCli !== null) {
354
363
  if (engineSpecificInstalled) {
@@ -380,6 +389,72 @@ export async function handleOpenShell(
380
389
  }
381
390
  }
382
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
+
383
458
  choices.push(new inquirer.Separator())
384
459
  choices.push({
385
460
  name: `${chalk.blue('←')} Back`,
@@ -390,7 +465,7 @@ export async function handleOpenShell(
390
465
  {
391
466
  type: 'list',
392
467
  name: 'shellChoice',
393
- message: 'Select shell option:',
468
+ message: 'Select console option:',
394
469
  choices,
395
470
  pageSize: getPageSize(),
396
471
  },
@@ -416,6 +491,56 @@ export async function handleOpenShell(
416
491
  return
417
492
  }
418
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
+
419
544
  // Handle Qdrant/Meilisearch API info display
420
545
  if (shellChoice === 'api-info') {
421
546
  console.log()
@@ -670,6 +795,42 @@ export async function handleOpenShell(
670
795
  return
671
796
  }
672
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
+
673
834
  // Handle install-webui option for Qdrant
674
835
  if (shellChoice === 'install-webui') {
675
836
  if (config.engine === 'qdrant') {
@@ -809,6 +970,383 @@ async function downloadQdrantWebUI(containerName: string): Promise<void> {
809
970
  await pressEnterToContinue()
810
971
  }
811
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
+
812
1350
  async function launchShell(
813
1351
  containerName: string,
814
1352
  config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>,