spindb 0.31.0 → 0.31.1

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 CHANGED
@@ -305,6 +305,13 @@ spindb run mydb -c "SELECT * FROM users" # Inline SQL
305
305
  spindb run mydb seed.js # JavaScript (MongoDB)
306
306
  spindb run mydb -c "SET foo bar" # Redis command
307
307
 
308
+ # Query with structured output
309
+ spindb query mydb "SELECT * FROM users LIMIT 5" # Tabular output
310
+ spindb query mydb "SELECT * FROM users" --json # JSON for scripting
311
+ spindb query mydb "users.find()" -d mydb # MongoDB/FerretDB
312
+ spindb query myredis "KEYS user:*" # Redis/Valkey
313
+ spindb query myqdrant "GET /collections" # REST API engines
314
+
308
315
  # Get connection string
309
316
  spindb url mydb # postgresql://postgres@localhost:5432/mydb
310
317
  spindb url mydb --copy # Copy to clipboard
@@ -0,0 +1,238 @@
1
+ import { Command } from 'commander'
2
+ import { existsSync } from 'fs'
3
+ import chalk from 'chalk'
4
+ import { containerManager } from '../../core/container-manager'
5
+ import { processManager } from '../../core/process-manager'
6
+ import { getEngine } from '../../engines'
7
+ import { promptInstallDependencies } from '../ui/prompts'
8
+ import { uiError, uiWarning } from '../ui/theme'
9
+ import { getMissingDependencies } from '../../core/dependency-manager'
10
+ import { Engine, isFileBasedEngine, type QueryResult } from '../../types'
11
+
12
+ /**
13
+ * Format a QueryResult as a table for terminal output
14
+ */
15
+ function formatTable(result: QueryResult): string {
16
+ if (result.columns.length === 0 || result.rows.length === 0) {
17
+ return '(0 rows)'
18
+ }
19
+
20
+ // Calculate column widths
21
+ const widths: number[] = result.columns.map((col) => col.length)
22
+
23
+ for (const row of result.rows) {
24
+ for (let i = 0; i < result.columns.length; i++) {
25
+ const col = result.columns[i]
26
+ const value = formatValue(row[col])
27
+ widths[i] = Math.max(widths[i], value.length)
28
+ }
29
+ }
30
+
31
+ // Build header
32
+ const header = result.columns
33
+ .map((col, i) => col.padEnd(widths[i]))
34
+ .join(' | ')
35
+
36
+ // Build separator
37
+ const separator = widths.map((w) => '-'.repeat(w)).join('-+-')
38
+
39
+ // Build rows
40
+ const rows = result.rows.map((row) =>
41
+ result.columns
42
+ .map((col, i) => formatValue(row[col]).padEnd(widths[i]))
43
+ .join(' | '),
44
+ )
45
+
46
+ // Combine
47
+ const lines = [header, separator, ...rows]
48
+
49
+ // Add row count
50
+ const countMsg =
51
+ result.rowCount === 1 ? '(1 row)' : `(${result.rowCount} rows)`
52
+ lines.push('')
53
+ lines.push(countMsg)
54
+
55
+ return lines.join('\n')
56
+ }
57
+
58
+ /**
59
+ * Format a value for display
60
+ */
61
+ function formatValue(value: unknown): string {
62
+ if (value === null || value === undefined) {
63
+ return 'NULL'
64
+ }
65
+ if (typeof value === 'object') {
66
+ return JSON.stringify(value)
67
+ }
68
+ return String(value)
69
+ }
70
+
71
+ export const queryCommand = new Command('query')
72
+ .description('Execute a query and return results')
73
+ .argument('<name>', 'Container name')
74
+ .argument('<query>', 'Query to execute')
75
+ .option('-d, --database <name>', 'Target database (defaults to primary)')
76
+ .option('--json', 'Output results as JSON')
77
+ .action(
78
+ async (
79
+ name: string,
80
+ query: string,
81
+ options: { database?: string; json?: boolean },
82
+ ) => {
83
+ try {
84
+ const containerName = name
85
+
86
+ const config = await containerManager.getConfig(containerName)
87
+ if (!config) {
88
+ if (options.json) {
89
+ console.log(
90
+ JSON.stringify({
91
+ error: `Container "${containerName}" not found`,
92
+ }),
93
+ )
94
+ } else {
95
+ console.error(uiError(`Container "${containerName}" not found`))
96
+ }
97
+ process.exit(1)
98
+ }
99
+
100
+ const { engine: engineName } = config
101
+
102
+ // File-based databases: check file exists instead of running status
103
+ if (isFileBasedEngine(engineName)) {
104
+ if (!existsSync(config.database)) {
105
+ if (options.json) {
106
+ console.log(
107
+ JSON.stringify({
108
+ error: `Database file not found: ${config.database}`,
109
+ }),
110
+ )
111
+ } else {
112
+ console.error(
113
+ uiError(`Database file not found: ${config.database}`),
114
+ )
115
+ }
116
+ process.exit(1)
117
+ }
118
+ } else {
119
+ // Server databases need to be running
120
+ const running = await processManager.isRunning(containerName, {
121
+ engine: engineName,
122
+ })
123
+ if (!running) {
124
+ if (options.json) {
125
+ console.log(
126
+ JSON.stringify({
127
+ error: `Container "${containerName}" is not running`,
128
+ }),
129
+ )
130
+ } else {
131
+ console.error(
132
+ uiError(
133
+ `Container "${containerName}" is not running. Start it first with: spindb start ${containerName}`,
134
+ ),
135
+ )
136
+ }
137
+ process.exit(1)
138
+ }
139
+ }
140
+
141
+ const engine = getEngine(engineName)
142
+
143
+ let missingDeps = await getMissingDependencies(engineName)
144
+ if (missingDeps.length > 0) {
145
+ if (options.json) {
146
+ console.log(
147
+ JSON.stringify({
148
+ error: `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
149
+ }),
150
+ )
151
+ process.exit(1)
152
+ }
153
+
154
+ console.log(
155
+ uiWarning(
156
+ `Missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
157
+ ),
158
+ )
159
+
160
+ const installed = await promptInstallDependencies(
161
+ missingDeps[0].binary,
162
+ engineName,
163
+ )
164
+
165
+ if (!installed) {
166
+ process.exit(1)
167
+ }
168
+
169
+ missingDeps = await getMissingDependencies(engineName)
170
+ if (missingDeps.length > 0) {
171
+ console.error(
172
+ uiError(
173
+ `Still missing tools: ${missingDeps.map((d) => d.name).join(', ')}`,
174
+ ),
175
+ )
176
+ process.exit(1)
177
+ }
178
+
179
+ console.log(chalk.green(' ✓ All required tools are now available'))
180
+ console.log()
181
+ }
182
+
183
+ const database = options.database || config.database
184
+
185
+ // Execute the query
186
+ const result = await engine.executeQuery(config, query, { database })
187
+
188
+ // Output results
189
+ if (options.json) {
190
+ // JSON mode: output just the rows array
191
+ console.log(JSON.stringify(result.rows, null, 2))
192
+ } else {
193
+ // Table mode
194
+ console.log(formatTable(result))
195
+ }
196
+ } catch (error) {
197
+ const e = error as Error
198
+
199
+ // Map of tool patterns to their engines
200
+ const toolPatternToEngine: Record<string, Engine> = {
201
+ 'psql not found': Engine.PostgreSQL,
202
+ 'mysql not found': Engine.MySQL,
203
+ 'mysql client not found': Engine.MySQL,
204
+ 'redis-cli not found': Engine.Redis,
205
+ 'mongosh not found': Engine.MongoDB,
206
+ 'sqlite3 not found': Engine.SQLite,
207
+ }
208
+
209
+ const matchingPattern = Object.keys(toolPatternToEngine).find((p) =>
210
+ e.message.toLowerCase().includes(p.toLowerCase()),
211
+ )
212
+
213
+ if (matchingPattern && !options.json) {
214
+ const missingTool = matchingPattern
215
+ .replace(' not found', '')
216
+ .replace(' client', '')
217
+ const toolEngine = toolPatternToEngine[matchingPattern]
218
+ const installed = await promptInstallDependencies(
219
+ missingTool,
220
+ toolEngine,
221
+ )
222
+ if (installed) {
223
+ console.log(
224
+ chalk.yellow(' Please re-run your command to continue.'),
225
+ )
226
+ }
227
+ process.exit(1)
228
+ }
229
+
230
+ if (options.json) {
231
+ console.log(JSON.stringify({ error: e.message }))
232
+ } else {
233
+ console.error(uiError(e.message))
234
+ }
235
+ process.exit(1)
236
+ }
237
+ },
238
+ )
package/cli/index.ts CHANGED
@@ -30,6 +30,7 @@ import { databasesCommand } from './commands/databases'
30
30
  import { pullCommand } from './commands/pull'
31
31
  import { whichCommand } from './commands/which'
32
32
  import { exportCommand } from './commands/export'
33
+ import { queryCommand } from './commands/query'
33
34
  import { updateManager } from '../core/update-manager'
34
35
  import { configManager } from '../core/config-manager'
35
36
  import { setCachedIconMode } from './constants'
@@ -142,6 +143,7 @@ export async function run(): Promise<void> {
142
143
  program.addCommand(pullCommand)
143
144
  program.addCommand(whichCommand)
144
145
  program.addCommand(exportCommand)
146
+ program.addCommand(queryCommand)
145
147
 
146
148
  if (process.argv.length <= 2) {
147
149
  // Only show update notification in interactive menu mode (once at startup)