spindb 0.34.0 → 0.34.5

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.
@@ -9,7 +9,7 @@ import { paths } from '../../config/paths'
9
9
  import { getEngine } from '../../engines'
10
10
  import { uiError, uiInfo, header } from '../ui/theme'
11
11
  import { getEngineIcon } from '../constants'
12
- import { Engine, type ContainerConfig } from '../../types'
12
+ import { isFileBasedEngine, type ContainerConfig } from '../../types'
13
13
 
14
14
  function formatDate(dateString: string): string {
15
15
  const date = new Date(dateString)
@@ -19,8 +19,8 @@ function formatDate(dateString: string): string {
19
19
  async function getActualStatus(
20
20
  config: ContainerConfig,
21
21
  ): Promise<'running' | 'stopped' | 'available' | 'missing'> {
22
- // SQLite: check file existence instead of running status
23
- if (config.engine === Engine.SQLite) {
22
+ // File-based engines: check file existence instead of running status
23
+ if (isFileBasedEngine(config.engine)) {
24
24
  const fileExists = existsSync(config.database)
25
25
  return fileExists ? 'available' : 'missing'
26
26
  }
@@ -59,11 +59,11 @@ async function displayContainerInfo(
59
59
  }
60
60
 
61
61
  const icon = getEngineIcon(config.engine)
62
- const isSQLite = config.engine === Engine.SQLite
62
+ const isFileBased = isFileBasedEngine(config.engine)
63
63
 
64
64
  // Status display based on engine type
65
65
  let statusDisplay: string
66
- if (isSQLite) {
66
+ if (isFileBased) {
67
67
  statusDisplay =
68
68
  actualStatus === 'available'
69
69
  ? chalk.blue('🔵 available')
@@ -87,8 +87,8 @@ async function displayContainerInfo(
87
87
  chalk.gray(' ') + chalk.white('Status:'.padEnd(14)) + statusDisplay,
88
88
  )
89
89
 
90
- // Show file path for SQLite, port for server databases
91
- if (isSQLite) {
90
+ // Show file path for file-based engines, port for server databases
91
+ if (isFileBased) {
92
92
  console.log(
93
93
  chalk.gray(' ') +
94
94
  chalk.white('File:'.padEnd(14)) +
@@ -113,8 +113,8 @@ async function displayContainerInfo(
113
113
  chalk.gray(formatDate(config.created)),
114
114
  )
115
115
 
116
- // Don't show data dir for SQLite (file path is already shown)
117
- if (!isSQLite) {
116
+ // Don't show data dir for file-based engines (file path is already shown)
117
+ if (!isFileBased) {
118
118
  console.log(
119
119
  chalk.gray(' ') +
120
120
  chalk.white('Data Dir:'.padEnd(14)) +
@@ -176,11 +176,11 @@ async function displayAllContainersInfo(
176
176
 
177
177
  for (const container of containers) {
178
178
  const actualStatus = await getActualStatus(container)
179
- const isSQLite = container.engine === Engine.SQLite
179
+ const isFileBased = isFileBasedEngine(container.engine)
180
180
 
181
181
  // Status display based on engine type
182
182
  let statusDisplay: string
183
- if (isSQLite) {
183
+ if (isFileBased) {
184
184
  statusDisplay =
185
185
  actualStatus === 'available'
186
186
  ? chalk.blue('🔵 available')
@@ -195,9 +195,9 @@ async function displayAllContainersInfo(
195
195
  // getEngineIcon() includes trailing space for consistent alignment
196
196
  const engineDisplay = `${getEngineIcon(container.engine)}${container.engine}`
197
197
 
198
- // Show truncated file path for SQLite instead of port
198
+ // Show truncated file path for file-based engines instead of port
199
199
  let portOrPath: string
200
- if (isSQLite) {
200
+ if (isFileBased) {
201
201
  const fileName = basename(container.database)
202
202
  // Truncate if longer than 8 chars to fit in 8-char column
203
203
  portOrPath = fileName.length > 8 ? fileName.slice(0, 7) + '…' : fileName
@@ -223,11 +223,21 @@ async function displayAllContainersInfo(
223
223
  )
224
224
  const running = statusChecks.filter((s) => s === 'running').length
225
225
  const stopped = statusChecks.filter((s) => s === 'stopped').length
226
+ const fileAvailable = statusChecks.filter((s) => s === 'available').length
227
+ const fileMissing = statusChecks.filter((s) => s === 'missing').length
228
+
229
+ const parts: string[] = []
230
+ if (running + stopped > 0) {
231
+ parts.push(`${running} running, ${stopped} stopped`)
232
+ }
233
+ if (fileAvailable + fileMissing > 0) {
234
+ parts.push(
235
+ `${fileAvailable} file-based available${fileMissing > 0 ? `, ${fileMissing} missing` : ''}`,
236
+ )
237
+ }
226
238
 
227
239
  console.log(
228
- chalk.gray(
229
- ` ${containers.length} container(s): ${running} running, ${stopped} stopped`,
230
- ),
240
+ chalk.gray(` ${containers.length} container(s): ${parts.join('; ')}`),
231
241
  )
232
242
  console.log()
233
243
 
@@ -6,20 +6,31 @@ import { containerManager } from '../../core/container-manager'
6
6
  import { getEngine } from '../../engines'
7
7
  import { uiInfo, uiError, formatBytes } from '../ui/theme'
8
8
  import { getEngineIcon } from '../constants'
9
- import { Engine } from '../../types'
9
+ import { Engine, isFileBasedEngine } from '../../types'
10
10
  import type { ContainerConfig } from '../../types'
11
- import { sqliteRegistry } from '../../engines/sqlite/registry'
12
11
  import {
13
- scanForUnregisteredSqliteFiles,
12
+ scanForUnregisteredFiles,
14
13
  deriveContainerName,
15
- } from '../../engines/sqlite/scanner'
14
+ getRegistryForEngine,
15
+ type UnregisteredFile,
16
+ } from '../../engines/file-based-utils'
17
+
18
+ type UnregisteredFileWithEngine = UnregisteredFile & { engine: Engine }
16
19
 
17
20
  /**
18
- * Prompt user about unregistered SQLite files in CWD
21
+ * Prompt user about unregistered file-based database files in CWD
19
22
  * Returns true if user registered any files (refresh needed)
20
23
  */
21
24
  async function promptUnregisteredFiles(): Promise<boolean> {
22
- const unregistered = await scanForUnregisteredSqliteFiles()
25
+ const [sqliteFiles, duckdbFiles] = await Promise.all([
26
+ scanForUnregisteredFiles(Engine.SQLite),
27
+ scanForUnregisteredFiles(Engine.DuckDB),
28
+ ])
29
+
30
+ const unregistered: UnregisteredFileWithEngine[] = [
31
+ ...sqliteFiles.map((f) => ({ ...f, engine: Engine.SQLite as Engine })),
32
+ ...duckdbFiles.map((f) => ({ ...f, engine: Engine.DuckDB as Engine })),
33
+ ]
23
34
 
24
35
  if (unregistered.length === 0) {
25
36
  return false
@@ -29,6 +40,7 @@ async function promptUnregisteredFiles(): Promise<boolean> {
29
40
 
30
41
  for (let i = 0; i < unregistered.length; i++) {
31
42
  const file = unregistered[i]
43
+ const engineLabel = file.engine === Engine.SQLite ? 'SQLite' : 'DuckDB'
32
44
  const prompt =
33
45
  unregistered.length > 1 ? `[${i + 1} of ${unregistered.length}] ` : ''
34
46
 
@@ -36,7 +48,7 @@ async function promptUnregisteredFiles(): Promise<boolean> {
36
48
  {
37
49
  type: 'list',
38
50
  name: 'action',
39
- message: `${prompt}Unregistered SQLite database "${file.fileName}" found in current directory. Register with SpinDB?`,
51
+ message: `${prompt}Unregistered ${engineLabel} database "${file.fileName}" found in current directory. Register with SpinDB?`,
40
52
  choices: [
41
53
  { name: 'Yes', value: 'yes' },
42
54
  { name: 'No', value: 'no' },
@@ -46,7 +58,11 @@ async function promptUnregisteredFiles(): Promise<boolean> {
46
58
  ])
47
59
 
48
60
  if (action === 'yes') {
49
- const suggestedName = deriveContainerName(file.fileName)
61
+ const registry = getRegistryForEngine(file.engine)
62
+ const suggestedName = deriveContainerName(
63
+ file.fileName,
64
+ file.engine as Engine.SQLite | Engine.DuckDB,
65
+ )
50
66
  const { containerName } = await inquirer.prompt<{
51
67
  containerName: string
52
68
  }>([
@@ -66,7 +82,7 @@ async function promptUnregisteredFiles(): Promise<boolean> {
66
82
  ])
67
83
 
68
84
  // Check if name already exists
69
- if (await sqliteRegistry.exists(containerName)) {
85
+ if (await registry.exists(containerName)) {
70
86
  console.log(
71
87
  chalk.yellow(
72
88
  ` Container "${containerName}" already exists. Skipping.`,
@@ -75,7 +91,7 @@ async function promptUnregisteredFiles(): Promise<boolean> {
75
91
  continue
76
92
  }
77
93
 
78
- await sqliteRegistry.add({
94
+ await registry.add({
79
95
  name: containerName,
80
96
  filePath: file.absolutePath,
81
97
  created: new Date().toISOString(),
@@ -85,7 +101,9 @@ async function promptUnregisteredFiles(): Promise<boolean> {
85
101
  )
86
102
  anyRegistered = true
87
103
  } else if (action === 'ignore') {
88
- await sqliteRegistry.addIgnoreFolder(dirname(file.absolutePath))
104
+ await getRegistryForEngine(file.engine).addIgnoreFolder(
105
+ dirname(file.absolutePath),
106
+ )
89
107
  console.log(chalk.gray(' Folder will be ignored in future scans.'))
90
108
  break // Exit early
91
109
  }
@@ -101,8 +119,8 @@ async function promptUnregisteredFiles(): Promise<boolean> {
101
119
  async function getContainerSize(
102
120
  container: ContainerConfig,
103
121
  ): Promise<number | null> {
104
- // SQLite can always get size (it's just file size)
105
- if (container.engine === Engine.SQLite) {
122
+ // File-based engines can always get size (it's just file size)
123
+ if (isFileBasedEngine(container.engine)) {
106
124
  try {
107
125
  const engine = getEngine(container.engine)
108
126
  return await engine.getDatabaseSize(container)
@@ -127,10 +145,10 @@ export const listCommand = new Command('list')
127
145
  .alias('ls')
128
146
  .description('List all containers')
129
147
  .option('--json', 'Output as JSON')
130
- .option('--no-scan', 'Skip scanning for unregistered SQLite files in CWD')
148
+ .option('--no-scan', 'Skip scanning for unregistered database files in CWD')
131
149
  .action(async (options: { json?: boolean; scan?: boolean }) => {
132
150
  try {
133
- // Scan for unregistered SQLite files in CWD (unless JSON mode or --no-scan)
151
+ // Scan for unregistered file-based database files in CWD (unless JSON mode or --no-scan)
134
152
  if (!options.json && options.scan !== false) {
135
153
  await promptUnregisteredFiles()
136
154
  }
@@ -173,9 +191,9 @@ export const listCommand = new Command('list')
173
191
  const container = containers[i]
174
192
  const size = sizes[i]
175
193
 
176
- // SQLite uses different status labels (blue/white icons)
194
+ // File-based engines use different status labels (blue/white icons)
177
195
  let statusDisplay: string
178
- if (container.engine === Engine.SQLite) {
196
+ if (isFileBasedEngine(container.engine)) {
179
197
  statusDisplay =
180
198
  container.status === 'running'
181
199
  ? chalk.blue('🔵 available')
@@ -193,9 +211,9 @@ export const listCommand = new Command('list')
193
211
 
194
212
  const sizeDisplay = size !== null ? formatBytes(size) : '—'
195
213
 
196
- // SQLite shows truncated file name instead of port
214
+ // File-based engines show truncated file name instead of port
197
215
  let portOrPath: string
198
- if (container.engine === Engine.SQLite) {
216
+ if (isFileBasedEngine(container.engine)) {
199
217
  const fileName = basename(container.database)
200
218
  // Truncate if longer than 8 chars to fit in 8-char column
201
219
  portOrPath =
@@ -219,10 +237,10 @@ export const listCommand = new Command('list')
219
237
  console.log()
220
238
 
221
239
  const serverContainers = containers.filter(
222
- (c) => c.engine !== Engine.SQLite,
240
+ (c) => !isFileBasedEngine(c.engine),
223
241
  )
224
- const sqliteContainers = containers.filter(
225
- (c) => c.engine === Engine.SQLite,
242
+ const fileBasedContainers = containers.filter((c) =>
243
+ isFileBasedEngine(c.engine),
226
244
  )
227
245
 
228
246
  const running = serverContainers.filter(
@@ -231,10 +249,10 @@ export const listCommand = new Command('list')
231
249
  const stopped = serverContainers.filter(
232
250
  (c) => c.status !== 'running',
233
251
  ).length
234
- const available = sqliteContainers.filter(
252
+ const available = fileBasedContainers.filter(
235
253
  (c) => c.status === 'running',
236
254
  ).length
237
- const missing = sqliteContainers.filter(
255
+ const missing = fileBasedContainers.filter(
238
256
  (c) => c.status !== 'running',
239
257
  ).length
240
258
 
@@ -242,9 +260,9 @@ export const listCommand = new Command('list')
242
260
  if (serverContainers.length > 0) {
243
261
  parts.push(`${running} running, ${stopped} stopped`)
244
262
  }
245
- if (sqliteContainers.length > 0) {
263
+ if (fileBasedContainers.length > 0) {
246
264
  parts.push(
247
- `${available} SQLite available${missing > 0 ? `, ${missing} missing` : ''}`,
265
+ `${available} file-based available${missing > 0 ? `, ${missing} missing` : ''}`,
248
266
  )
249
267
  }
250
268
 
@@ -42,6 +42,7 @@ import {
42
42
  import { getEngine } from '../../../engines'
43
43
  import { createSpinner } from '../../ui/spinner'
44
44
  import { uiError, uiWarning, uiInfo, uiSuccess } from '../../ui/theme'
45
+ import { logDebug } from '../../../core/error-handler'
45
46
  import { pressEnterToContinue } from './shared'
46
47
  import { paths } from '../../../config/paths'
47
48
  import { getEngineConfig } from '../../../config/engines-registry'
@@ -237,8 +238,8 @@ export async function handleOpenShell(
237
238
  engineSpecificValue = null
238
239
  engineSpecificInstallValue = null
239
240
  } else if (config.engine === 'influxdb') {
240
- // InfluxDB uses REST API, no interactive shell
241
- defaultShellName = 'Web Dashboard'
241
+ // InfluxDB uses influxdb3 query subcommand (same binary as server)
242
+ defaultShellName = 'influxdb3 query'
242
243
  engineSpecificCli = null
243
244
  engineSpecificInstalled = false
244
245
  engineSpecificValue = null
@@ -334,7 +335,11 @@ export async function handleOpenShell(
334
335
  value: 'api-info',
335
336
  })
336
337
  } else if (config.engine === 'influxdb') {
337
- // InfluxDB: REST API only, no web dashboard or interactive shell
338
+ // InfluxDB: influxdb3 query CLI + API info
339
+ choices.push({
340
+ name: `▸ Use default shell (influxdb3 query)`,
341
+ value: 'default',
342
+ })
338
343
  choices.push({
339
344
  name: `ℹ Show API info`,
340
345
  value: 'api-info',
@@ -353,7 +358,7 @@ export async function handleOpenShell(
353
358
  } else {
354
359
  // Non-REST-API engines: show default shell option
355
360
  choices.push({
356
- name: `>_ Use default shell (${defaultShellName})`,
361
+ name: `▸ Use default shell (${defaultShellName})`,
357
362
  value: 'default',
358
363
  })
359
364
  }
@@ -362,7 +367,7 @@ export async function handleOpenShell(
362
367
  if (engineSpecificCli !== null) {
363
368
  if (engineSpecificInstalled) {
364
369
  choices.push({
365
- name: `⚡ Use ${engineSpecificCli} (enhanced features, recommended)`,
370
+ name: `★ Use ${engineSpecificCli} (enhanced features, recommended)`,
366
371
  value: engineSpecificValue!,
367
372
  })
368
373
  } else {
@@ -378,7 +383,7 @@ export async function handleOpenShell(
378
383
  if (engineConfig.queryLanguage === 'sql') {
379
384
  if (usqlInstalled) {
380
385
  choices.push({
381
- name: ' Use usql (universal SQL client)',
386
+ name: ' Use usql (universal SQL client)',
382
387
  value: 'usql',
383
388
  })
384
389
  } else {
@@ -394,7 +399,7 @@ export async function handleOpenShell(
394
399
  const dblabPath = await configManager.getBinaryPath('dblab')
395
400
  if (dblabPath) {
396
401
  choices.push({
397
- name: ' Use dblab (visual TUI)',
402
+ name: ' Use dblab (visual TUI)',
398
403
  value: 'dblab',
399
404
  })
400
405
  } else {
@@ -415,6 +420,15 @@ export async function handleOpenShell(
415
420
  })
416
421
  }
417
422
 
423
+ if (config.engine === 'questdb') {
424
+ const httpPort = config.port + 188
425
+ choices.push(new inquirer.Separator(chalk.gray(`───── Web Panel ─────`)))
426
+ choices.push({
427
+ name: `◎ Open Web Console (port ${httpPort})`,
428
+ value: 'browser',
429
+ })
430
+ }
431
+
418
432
  if (config.engine === 'duckdb') {
419
433
  choices.push(new inquirer.Separator(chalk.gray(`───── Web Panel ─────`)))
420
434
  choices.push({
@@ -487,6 +501,16 @@ export async function handleOpenShell(
487
501
  console.log()
488
502
  openInBrowser(playUrl)
489
503
  await pressEnterToContinue()
504
+ } else if (config.engine === 'questdb') {
505
+ // QuestDB Web Console on HTTP port (PG port + 188)
506
+ const httpPort = config.port + 188
507
+ const consoleUrl = `http://127.0.0.1:${httpPort}`
508
+ console.log()
509
+ console.log(uiInfo(`Opening QuestDB Web Console in browser...`))
510
+ console.log(chalk.gray(` ${consoleUrl}`))
511
+ console.log()
512
+ openInBrowser(consoleUrl)
513
+ await pressEnterToContinue()
490
514
  }
491
515
  return
492
516
  }
@@ -1482,22 +1506,109 @@ async function launchShell(
1482
1506
  await pressEnterToContinue()
1483
1507
  return
1484
1508
  } 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()
1509
+ // InfluxDB: influxdb3 query is one-shot (no REPL), use interactive loop
1510
+ const engine = getEngine(config.engine)
1511
+ const influxdbPath = await engine
1512
+ .getInfluxDBPath(config.version)
1513
+ .catch(() => null)
1514
+ if (!influxdbPath) {
1515
+ console.log(
1516
+ uiWarning('influxdb3 not found. Run: spindb engines download influxdb'),
1517
+ )
1518
+ await pressEnterToContinue()
1519
+ return
1520
+ }
1521
+ // Query available databases from the REST API
1522
+ let db = database || config.name
1523
+ try {
1524
+ const resp = await fetch(
1525
+ `http://127.0.0.1:${config.port}/api/v3/configure/database?format=json`,
1526
+ )
1527
+ if (resp.ok) {
1528
+ const databases = (await resp.json()) as Array<Record<string, string>>
1529
+ const dbNames = databases
1530
+ .map((d) => d['iox::database'] || d.name)
1531
+ .filter((n) => n && n !== '_internal')
1532
+ if (dbNames.length === 0) {
1533
+ console.log(
1534
+ uiWarning(
1535
+ 'No databases exist yet. Write data first to create a database.',
1536
+ ),
1537
+ )
1538
+ console.log(
1539
+ chalk.gray(
1540
+ ` curl -X POST "http://127.0.0.1:${config.port}/api/v3/write_lp?db=${db}" -H "Content-Type: text/plain" -d 'measurement,tag=value field=1'`,
1541
+ ),
1542
+ )
1543
+ console.log()
1544
+ await pressEnterToContinue()
1545
+ return
1546
+ }
1547
+ if (!dbNames.includes(db)) {
1548
+ if (dbNames.length === 1) {
1549
+ db = dbNames[0]
1550
+ } else {
1551
+ const { chosenDb } = await escapeablePrompt<{ chosenDb: string }>([
1552
+ {
1553
+ type: 'list',
1554
+ name: 'chosenDb',
1555
+ message: 'Select database:',
1556
+ choices: dbNames,
1557
+ },
1558
+ ])
1559
+ db = chosenDb
1560
+ }
1561
+ }
1562
+ }
1563
+ } catch {
1564
+ // Server may not support this endpoint; proceed with default db
1565
+ }
1566
+ console.log(chalk.cyan(`InfluxDB SQL Console (${db})`))
1567
+ console.log(chalk.gray(` Type SQL queries, or "exit" to quit.\n`))
1568
+ let running = true
1569
+ while (running) {
1570
+ const { sql } = await escapeablePrompt<{ sql: string }>([
1571
+ {
1572
+ type: 'input',
1573
+ name: 'sql',
1574
+ message: chalk.blue('sql>'),
1575
+ },
1576
+ ])
1577
+ const trimmed = (sql || '').trim()
1578
+ if (
1579
+ trimmed.toLowerCase() === 'exit' ||
1580
+ trimmed.toLowerCase() === 'quit'
1581
+ ) {
1582
+ running = false
1583
+ break
1584
+ }
1585
+ if (!trimmed) {
1586
+ continue
1587
+ }
1588
+ const queryProcess = spawn(
1589
+ influxdbPath,
1590
+ [
1591
+ 'query',
1592
+ '--host',
1593
+ `http://127.0.0.1:${config.port}`,
1594
+ '--database',
1595
+ db,
1596
+ '--',
1597
+ trimmed,
1598
+ ],
1599
+ { stdio: 'inherit' },
1600
+ )
1601
+ await new Promise<void>((resolve) => {
1602
+ queryProcess.on('error', (err) => {
1603
+ console.error(uiError(`Query failed: ${err.message}`))
1604
+ resolve()
1605
+ })
1606
+ queryProcess.on('close', () => {
1607
+ logDebug('influxdb query process exited')
1608
+ resolve()
1609
+ })
1610
+ })
1611
+ }
1501
1612
  return
1502
1613
  } else if (config.engine === 'couchdb') {
1503
1614
  // CouchDB: Open Fauxton dashboard in browser (served at /_utils)
@@ -5,6 +5,7 @@ import { spawn } from 'child_process'
5
5
  import { containerManager } from '../../../core/container-manager'
6
6
  import { getMissingDependencies } from '../../../core/dependency-manager'
7
7
  import { getEngine } from '../../../engines'
8
+ import { Engine } from '../../../types'
8
9
  import { paths } from '../../../config/paths'
9
10
  import { promptInstallDependencies, escapeablePrompt } from '../../ui/prompts'
10
11
  import { uiError, uiWarning, uiInfo, uiSuccess } from '../../ui/theme'
@@ -92,7 +93,51 @@ export async function handleRunSql(
92
93
  const filePath = stripQuotes(rawFilePath)
93
94
 
94
95
  // Use provided database or fall back to container's default
95
- const databaseName = database || config.database
96
+ let databaseName = database || config.database
97
+
98
+ // InfluxDB: discover real databases (they're created implicitly on write)
99
+ // Cannot use engine.listDatabases() here because it falls back to
100
+ // container.database when the list is empty, hiding the "no databases"
101
+ // state that we need to detect for the .lp warning.
102
+ if (config.engine === Engine.InfluxDB) {
103
+ try {
104
+ const resp = await fetch(
105
+ `http://127.0.0.1:${config.port}/api/v3/configure/database?format=json`,
106
+ )
107
+ if (resp.ok) {
108
+ const databases = (await resp.json()) as Array<Record<string, string>>
109
+ const dbNames = databases
110
+ .map((d) => d['iox::database'] || d.name)
111
+ .filter((n) => n && n !== '_internal')
112
+ if (dbNames.length === 0 && !filePath.endsWith('.lp')) {
113
+ console.log(
114
+ uiWarning(
115
+ 'No databases exist yet. Seed data with a .lp file first.',
116
+ ),
117
+ )
118
+ await pressEnterToContinue()
119
+ return
120
+ }
121
+ if (!dbNames.includes(databaseName)) {
122
+ if (dbNames.length === 1) {
123
+ databaseName = dbNames[0]
124
+ } else if (dbNames.length > 1) {
125
+ const { chosenDb } = await escapeablePrompt<{ chosenDb: string }>([
126
+ {
127
+ type: 'list',
128
+ name: 'chosenDb',
129
+ message: 'Select database:',
130
+ choices: dbNames,
131
+ },
132
+ ])
133
+ databaseName = chosenDb
134
+ }
135
+ }
136
+ }
137
+ } catch {
138
+ // Proceed with default
139
+ }
140
+ }
96
141
 
97
142
  console.log()
98
143
  console.log(uiInfo(`Running ${scriptType} file against "${databaseName}"...`))
@@ -11,7 +11,7 @@ import { checkEngineDependencies } from '../../../core/dependency-manager'
11
11
  import { createSpinner } from '../../ui/spinner'
12
12
  import { header, uiSuccess, uiError, uiWarning, uiInfo } from '../../ui/theme'
13
13
  import { pressEnterToContinue } from './shared'
14
- import { Engine } from '../../../types'
14
+ import { type Engine, isFileBasedEngine } from '../../../types'
15
15
 
16
16
  export async function handleCheckUpdate(): Promise<void> {
17
17
  console.clear()
@@ -189,7 +189,7 @@ async function checkContainers(): Promise<HealthCheckResult> {
189
189
  }
190
190
 
191
191
  const details = Object.entries(byEngine).map(([engine, counts]) => {
192
- if (engine === Engine.SQLite) {
192
+ if (isFileBasedEngine(engine as Engine)) {
193
193
  return `${engine}: ${counts.running} exist, ${counts.stopped} missing`
194
194
  }
195
195
  return `${engine}: ${counts.running} running, ${counts.stopped} stopped`
@@ -7,8 +7,13 @@ import {
7
7
  scanForUnregisteredSqliteFiles,
8
8
  deriveContainerName,
9
9
  } from '../../engines/sqlite/scanner'
10
+ import {
11
+ isValidExtensionForEngine,
12
+ formatExtensionsForEngine,
13
+ } from '../../engines/file-based-utils'
10
14
  import { containerManager } from '../../core/container-manager'
11
15
  import { uiSuccess, uiError, uiInfo } from '../ui/theme'
16
+ import { Engine } from '../../types'
12
17
  import { detachCommand } from './detach'
13
18
 
14
19
  export const sqliteCommand = new Command('sqlite').description(
@@ -146,6 +151,22 @@ sqliteCommand
146
151
  try {
147
152
  const absolutePath = resolve(path)
148
153
 
154
+ // Validate extension matches SQLite
155
+ if (!isValidExtensionForEngine(absolutePath, Engine.SQLite)) {
156
+ const msg = `File extension must be one of: ${formatExtensionsForEngine(Engine.SQLite)}`
157
+ if (options.json) {
158
+ console.log(JSON.stringify({ success: false, error: msg }))
159
+ } else {
160
+ console.error(uiError(msg))
161
+ console.log(
162
+ chalk.gray(
163
+ ' For DuckDB files, use: spindb duckdb attach <path>',
164
+ ),
165
+ )
166
+ }
167
+ process.exit(1)
168
+ }
169
+
149
170
  if (!existsSync(absolutePath)) {
150
171
  if (options.json) {
151
172
  console.log(
package/cli/index.ts CHANGED
@@ -27,6 +27,7 @@ import { doctorCommand } from './commands/doctor'
27
27
  import { attachCommand } from './commands/attach'
28
28
  import { detachCommand } from './commands/detach'
29
29
  import { sqliteCommand } from './commands/sqlite'
30
+ import { duckdbCommand } from './commands/duckdb'
30
31
  import { databasesCommand } from './commands/databases'
31
32
  import { pullCommand } from './commands/pull'
32
33
  import { whichCommand } from './commands/which'
@@ -142,6 +143,7 @@ export async function run(): Promise<void> {
142
143
  program.addCommand(attachCommand)
143
144
  program.addCommand(detachCommand)
144
145
  program.addCommand(sqliteCommand)
146
+ program.addCommand(duckdbCommand)
145
147
  program.addCommand(databasesCommand)
146
148
  program.addCommand(pullCommand)
147
149
  program.addCommand(whichCommand)
@@ -291,8 +291,8 @@
291
291
  "defaultVersion": "3.8.0",
292
292
  "defaultPort": 8086,
293
293
  "runtime": "server",
294
- "queryLanguage": "sql",
295
- "scriptFileLabel": null,
294
+ "queryLanguage": "rest",
295
+ "scriptFileLabel": "Run LP/SQL file",
296
296
  "connectionScheme": "http",
297
297
  "superuser": null,
298
298
  "clientTools": ["influxdb3"],
@@ -161,6 +161,14 @@ export abstract class BaseEngine {
161
161
  throw new Error('typedb_console_bin not found')
162
162
  }
163
163
 
164
+ /**
165
+ * Get the path to the influxdb3 binary if available
166
+ * Default implementation throws; InfluxDB engine overrides this method.
167
+ */
168
+ async getInfluxDBPath(_version?: string): Promise<string> {
169
+ throw new Error('influxdb3 not found')
170
+ }
171
+
164
172
  /**
165
173
  * Get the path to the sqlite3 client if available
166
174
  * Default implementation returns null; SQLite engine overrides this method.