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.
- package/README.md +13 -1
- package/cli/commands/attach.ts +38 -9
- package/cli/commands/backups.ts +5 -0
- package/cli/commands/connect.ts +6 -6
- package/cli/commands/detach.ts +16 -9
- package/cli/commands/doctor.ts +2 -2
- package/cli/commands/duckdb.ts +273 -0
- package/cli/commands/edit.ts +31 -21
- package/cli/commands/info.ts +26 -16
- package/cli/commands/list.ts +44 -26
- package/cli/commands/menu/shell-handlers.ts +134 -23
- package/cli/commands/menu/sql-handlers.ts +46 -1
- package/cli/commands/menu/update-handlers.ts +2 -2
- package/cli/commands/sqlite.ts +21 -0
- package/cli/index.ts +2 -0
- package/config/engines.json +2 -2
- package/engines/base-engine.ts +8 -0
- package/engines/duckdb/scanner.ts +22 -0
- package/engines/file-based-utils.ts +262 -0
- package/engines/influxdb/index.ts +46 -1
- package/engines/sqlite/scanner.ts +11 -88
- package/package.json +1 -1
package/cli/commands/info.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
23
|
-
if (config.engine
|
|
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
|
|
62
|
+
const isFileBased = isFileBasedEngine(config.engine)
|
|
63
63
|
|
|
64
64
|
// Status display based on engine type
|
|
65
65
|
let statusDisplay: string
|
|
66
|
-
if (
|
|
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
|
|
91
|
-
if (
|
|
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
|
|
117
|
-
if (!
|
|
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
|
|
179
|
+
const isFileBased = isFileBasedEngine(container.engine)
|
|
180
180
|
|
|
181
181
|
// Status display based on engine type
|
|
182
182
|
let statusDisplay: string
|
|
183
|
-
if (
|
|
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
|
|
198
|
+
// Show truncated file path for file-based engines instead of port
|
|
199
199
|
let portOrPath: string
|
|
200
|
-
if (
|
|
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
|
|
package/cli/commands/list.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
+
scanForUnregisteredFiles,
|
|
14
13
|
deriveContainerName,
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
105
|
-
if (container.engine
|
|
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
|
|
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
|
|
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
|
-
//
|
|
194
|
+
// File-based engines use different status labels (blue/white icons)
|
|
177
195
|
let statusDisplay: string
|
|
178
|
-
if (container.engine
|
|
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
|
-
//
|
|
214
|
+
// File-based engines show truncated file name instead of port
|
|
197
215
|
let portOrPath: string
|
|
198
|
-
if (container.engine
|
|
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
|
|
240
|
+
(c) => !isFileBasedEngine(c.engine),
|
|
223
241
|
)
|
|
224
|
-
const
|
|
225
|
-
(c
|
|
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 =
|
|
252
|
+
const available = fileBasedContainers.filter(
|
|
235
253
|
(c) => c.status === 'running',
|
|
236
254
|
).length
|
|
237
|
-
const missing =
|
|
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 (
|
|
263
|
+
if (fileBasedContainers.length > 0) {
|
|
246
264
|
parts.push(
|
|
247
|
-
`${available}
|
|
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
|
|
241
|
-
defaultShellName = '
|
|
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:
|
|
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:
|
|
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:
|
|
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: '
|
|
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: '
|
|
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:
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
package/cli/commands/sqlite.ts
CHANGED
|
@@ -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)
|
package/config/engines.json
CHANGED
|
@@ -291,8 +291,8 @@
|
|
|
291
291
|
"defaultVersion": "3.8.0",
|
|
292
292
|
"defaultPort": 8086,
|
|
293
293
|
"runtime": "server",
|
|
294
|
-
"queryLanguage": "
|
|
295
|
-
"scriptFileLabel":
|
|
294
|
+
"queryLanguage": "rest",
|
|
295
|
+
"scriptFileLabel": "Run LP/SQL file",
|
|
296
296
|
"connectionScheme": "http",
|
|
297
297
|
"superuser": null,
|
|
298
298
|
"clientTools": ["influxdb3"],
|
package/engines/base-engine.ts
CHANGED
|
@@ -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.
|