spindb 0.8.1 → 0.9.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.
- package/README.md +80 -7
- package/cli/commands/connect.ts +115 -14
- package/cli/commands/create.ts +113 -1
- package/cli/commands/doctor.ts +319 -0
- package/cli/commands/edit.ts +203 -5
- package/cli/commands/info.ts +79 -26
- package/cli/commands/list.ts +64 -9
- package/cli/commands/menu/backup-handlers.ts +28 -13
- package/cli/commands/menu/container-handlers.ts +410 -120
- package/cli/commands/menu/index.ts +5 -1
- package/cli/commands/menu/shell-handlers.ts +105 -21
- package/cli/commands/menu/sql-handlers.ts +16 -4
- package/cli/commands/menu/update-handlers.ts +278 -0
- package/cli/commands/run.ts +27 -11
- package/cli/commands/url.ts +17 -9
- package/cli/constants.ts +1 -0
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +165 -14
- package/config/engine-defaults.ts +14 -0
- package/config/os-dependencies.ts +66 -0
- package/config/paths.ts +8 -0
- package/core/container-manager.ts +119 -11
- package/core/dependency-manager.ts +18 -0
- package/engines/index.ts +4 -0
- package/engines/sqlite/index.ts +597 -0
- package/engines/sqlite/registry.ts +185 -0
- package/package.json +3 -2
- package/types/index.ts +26 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite Engine
|
|
3
|
+
*
|
|
4
|
+
* SQLite is a file-based embedded database with no server process.
|
|
5
|
+
* Key differences from PostgreSQL/MySQL:
|
|
6
|
+
* - No start/stop operations (file-based)
|
|
7
|
+
* - No port management
|
|
8
|
+
* - Database files stored in user project directories (not ~/.spindb/)
|
|
9
|
+
* - Uses a registry to track file paths
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn, execFile } from 'child_process'
|
|
13
|
+
import { promisify } from 'util'
|
|
14
|
+
import { existsSync, statSync, createReadStream, createWriteStream } from 'fs'
|
|
15
|
+
import { copyFile, unlink, mkdir, open, writeFile } from 'fs/promises'
|
|
16
|
+
import { resolve, dirname, join } from 'path'
|
|
17
|
+
import { tmpdir } from 'os'
|
|
18
|
+
import { BaseEngine } from '../base-engine'
|
|
19
|
+
import { sqliteRegistry } from './registry'
|
|
20
|
+
import { configManager } from '../../core/config-manager'
|
|
21
|
+
import { getEngineDefaults } from '../../config/engine-defaults'
|
|
22
|
+
import type {
|
|
23
|
+
ContainerConfig,
|
|
24
|
+
ProgressCallback,
|
|
25
|
+
BackupFormat,
|
|
26
|
+
BackupOptions,
|
|
27
|
+
BackupResult,
|
|
28
|
+
RestoreResult,
|
|
29
|
+
DumpResult,
|
|
30
|
+
StatusResult,
|
|
31
|
+
} from '../../types'
|
|
32
|
+
|
|
33
|
+
const execFileAsync = promisify(execFile)
|
|
34
|
+
const engineDef = getEngineDefaults('sqlite')
|
|
35
|
+
|
|
36
|
+
export class SQLiteEngine extends BaseEngine {
|
|
37
|
+
name = 'sqlite'
|
|
38
|
+
displayName = 'SQLite'
|
|
39
|
+
defaultPort = 0 // File-based, no port
|
|
40
|
+
supportedVersions = engineDef.supportedVersions
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* SQLite uses system binaries - no download URL
|
|
44
|
+
*/
|
|
45
|
+
getBinaryUrl(): string {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'SQLite uses system-installed binaries. Install sqlite3:\n' +
|
|
48
|
+
' macOS: brew install sqlite (or use built-in /usr/bin/sqlite3)\n' +
|
|
49
|
+
' Ubuntu/Debian: sudo apt install sqlite3',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verify sqlite3 binary exists
|
|
55
|
+
*/
|
|
56
|
+
async verifyBinary(): Promise<boolean> {
|
|
57
|
+
return this.isBinaryInstalled('3')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if sqlite3 is installed on the system
|
|
62
|
+
*/
|
|
63
|
+
async isBinaryInstalled(_version: string): Promise<boolean> {
|
|
64
|
+
const sqlite3Path = await this.getSqlite3Path()
|
|
65
|
+
return sqlite3Path !== null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ensure sqlite3 is available
|
|
70
|
+
* SQLite uses system binaries, so this just verifies it exists
|
|
71
|
+
*/
|
|
72
|
+
async ensureBinaries(
|
|
73
|
+
_version: string,
|
|
74
|
+
_onProgress?: ProgressCallback,
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
const sqlite3Path = await this.getSqlite3Path()
|
|
77
|
+
if (!sqlite3Path) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'sqlite3 not found. Install SQLite:\n' +
|
|
80
|
+
' macOS: brew install sqlite (or use built-in /usr/bin/sqlite3)\n' +
|
|
81
|
+
' Ubuntu/Debian: sudo apt install sqlite3\n' +
|
|
82
|
+
' Fedora: sudo dnf install sqlite',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return sqlite3Path
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get path to sqlite3 binary
|
|
90
|
+
* First checks config manager, then falls back to system PATH
|
|
91
|
+
*/
|
|
92
|
+
async getSqlite3Path(): Promise<string | null> {
|
|
93
|
+
// Check config manager first
|
|
94
|
+
const configPath = await configManager.getBinaryPath('sqlite3')
|
|
95
|
+
if (configPath) {
|
|
96
|
+
return configPath
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check system PATH
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await execFileAsync('which', ['sqlite3'])
|
|
102
|
+
const path = stdout.trim()
|
|
103
|
+
return path || null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get path to litecli (enhanced SQLite CLI)
|
|
111
|
+
*/
|
|
112
|
+
async getLitecliPath(): Promise<string | null> {
|
|
113
|
+
// Check config manager first
|
|
114
|
+
const configPath = await configManager.getBinaryPath('litecli')
|
|
115
|
+
if (configPath) {
|
|
116
|
+
return configPath
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check system PATH
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await execFileAsync('which', ['litecli'])
|
|
122
|
+
const path = stdout.trim()
|
|
123
|
+
return path || null
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Initialize a new SQLite database file
|
|
131
|
+
* Creates an empty database at the specified path (or CWD)
|
|
132
|
+
*/
|
|
133
|
+
async initDataDir(
|
|
134
|
+
containerName: string,
|
|
135
|
+
_version: string,
|
|
136
|
+
options: Record<string, unknown> = {},
|
|
137
|
+
): Promise<string> {
|
|
138
|
+
// Determine file path - default to CWD
|
|
139
|
+
const pathOption = options.path as string | undefined
|
|
140
|
+
const filePath = pathOption || `./${containerName}.sqlite`
|
|
141
|
+
const absolutePath = resolve(filePath)
|
|
142
|
+
|
|
143
|
+
// Ensure parent directory exists
|
|
144
|
+
const dir = dirname(absolutePath)
|
|
145
|
+
if (!existsSync(dir)) {
|
|
146
|
+
await mkdir(dir, { recursive: true })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if file already exists
|
|
150
|
+
if (existsSync(absolutePath)) {
|
|
151
|
+
throw new Error(`File already exists: ${absolutePath}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check if this path is already registered
|
|
155
|
+
if (await sqliteRegistry.isPathRegistered(absolutePath)) {
|
|
156
|
+
throw new Error(`Path is already registered: ${absolutePath}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Create empty database by running a simple query
|
|
160
|
+
const sqlite3 = await this.getSqlite3Path()
|
|
161
|
+
if (!sqlite3) {
|
|
162
|
+
throw new Error('sqlite3 not found')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await execFileAsync(sqlite3, [absolutePath, 'SELECT 1'])
|
|
166
|
+
|
|
167
|
+
// Register in the SQLite registry
|
|
168
|
+
await sqliteRegistry.add({
|
|
169
|
+
name: containerName,
|
|
170
|
+
filePath: absolutePath,
|
|
171
|
+
created: new Date().toISOString(),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return absolutePath
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Start is a no-op for SQLite (file-based, no server)
|
|
179
|
+
* Just verifies the file exists
|
|
180
|
+
*/
|
|
181
|
+
async start(
|
|
182
|
+
container: ContainerConfig,
|
|
183
|
+
_onProgress?: ProgressCallback,
|
|
184
|
+
): Promise<{ port: number; connectionString: string }> {
|
|
185
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
186
|
+
if (!entry) {
|
|
187
|
+
throw new Error(`SQLite container "${container.name}" not found in registry`)
|
|
188
|
+
}
|
|
189
|
+
if (!existsSync(entry.filePath)) {
|
|
190
|
+
throw new Error(`SQLite database file not found: ${entry.filePath}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
port: 0,
|
|
195
|
+
connectionString: this.getConnectionString(container),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Stop is a no-op for SQLite (file-based, no server)
|
|
201
|
+
*/
|
|
202
|
+
async stop(_container: ContainerConfig): Promise<void> {
|
|
203
|
+
// No-op: SQLite is file-based, no server to stop
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get status - check if the file exists
|
|
208
|
+
*/
|
|
209
|
+
async status(container: ContainerConfig): Promise<StatusResult> {
|
|
210
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
211
|
+
if (!entry) {
|
|
212
|
+
return {
|
|
213
|
+
running: false,
|
|
214
|
+
message: 'Not registered in SQLite registry',
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!existsSync(entry.filePath)) {
|
|
218
|
+
return {
|
|
219
|
+
running: false,
|
|
220
|
+
message: `File not found: ${entry.filePath}`,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
running: true,
|
|
225
|
+
message: 'Database file exists',
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get connection string for SQLite
|
|
231
|
+
* Returns sqlite:// URL format
|
|
232
|
+
*/
|
|
233
|
+
getConnectionString(container: ContainerConfig, _database?: string): string {
|
|
234
|
+
// container.database stores the file path for SQLite
|
|
235
|
+
const filePath = container.database
|
|
236
|
+
return `sqlite:///${filePath}`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Open interactive SQLite shell
|
|
241
|
+
* Prefers litecli if available, falls back to sqlite3
|
|
242
|
+
*/
|
|
243
|
+
async connect(container: ContainerConfig, _database?: string): Promise<void> {
|
|
244
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
245
|
+
if (!entry) {
|
|
246
|
+
throw new Error(`SQLite container "${container.name}" not found in registry`)
|
|
247
|
+
}
|
|
248
|
+
if (!existsSync(entry.filePath)) {
|
|
249
|
+
throw new Error(`SQLite database file not found: ${entry.filePath}`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Try litecli first, fall back to sqlite3
|
|
253
|
+
const litecli = await this.getLitecliPath()
|
|
254
|
+
const sqlite3 = await this.getSqlite3Path()
|
|
255
|
+
|
|
256
|
+
const cmd = litecli || sqlite3
|
|
257
|
+
if (!cmd) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
'sqlite3 not found. Install SQLite:\n' +
|
|
260
|
+
' macOS: brew install sqlite\n' +
|
|
261
|
+
' Ubuntu/Debian: sudo apt install sqlite3',
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const proc = spawn(cmd, [entry.filePath], { stdio: 'inherit' })
|
|
267
|
+
|
|
268
|
+
proc.on('error', (err: NodeJS.ErrnoException) => {
|
|
269
|
+
reject(err)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
proc.on('close', () => resolve())
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create database is a no-op for SQLite
|
|
278
|
+
* In SQLite, the file IS the database
|
|
279
|
+
*/
|
|
280
|
+
async createDatabase(
|
|
281
|
+
_container: ContainerConfig,
|
|
282
|
+
_database: string,
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
// No-op: SQLite file IS the database
|
|
285
|
+
// If you need multiple "databases", create multiple containers
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Drop database - deletes the file and removes from registry
|
|
290
|
+
*/
|
|
291
|
+
async dropDatabase(
|
|
292
|
+
container: ContainerConfig,
|
|
293
|
+
_database: string,
|
|
294
|
+
): Promise<void> {
|
|
295
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
296
|
+
if (entry && existsSync(entry.filePath)) {
|
|
297
|
+
await unlink(entry.filePath)
|
|
298
|
+
}
|
|
299
|
+
await sqliteRegistry.remove(container.name)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get database size by checking file size
|
|
304
|
+
*/
|
|
305
|
+
async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
|
|
306
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
307
|
+
if (!entry || !existsSync(entry.filePath)) {
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
const stats = statSync(entry.filePath)
|
|
311
|
+
return stats.size
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Detect backup format
|
|
316
|
+
* SQLite backups are either .sql (dump) or .sqlite/.db (file copy)
|
|
317
|
+
*/
|
|
318
|
+
async detectBackupFormat(filePath: string): Promise<BackupFormat> {
|
|
319
|
+
if (filePath.endsWith('.sql')) {
|
|
320
|
+
return {
|
|
321
|
+
format: 'sql',
|
|
322
|
+
description: 'SQLite SQL dump',
|
|
323
|
+
restoreCommand: 'sqlite3 <db> < <file>',
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
format: 'sqlite',
|
|
328
|
+
description: 'SQLite database file (binary copy)',
|
|
329
|
+
restoreCommand: 'cp <file> <db>',
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Create a backup of the SQLite database
|
|
335
|
+
*/
|
|
336
|
+
async backup(
|
|
337
|
+
container: ContainerConfig,
|
|
338
|
+
outputPath: string,
|
|
339
|
+
options: BackupOptions,
|
|
340
|
+
): Promise<BackupResult> {
|
|
341
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
342
|
+
if (!entry || !existsSync(entry.filePath)) {
|
|
343
|
+
throw new Error('SQLite database file not found')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (options.format === 'sql') {
|
|
347
|
+
// Use .dump command for SQL format
|
|
348
|
+
const sqlite3 = await this.getSqlite3Path()
|
|
349
|
+
if (!sqlite3) {
|
|
350
|
+
throw new Error('sqlite3 not found')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Pipe .dump output to file (avoids shell injection)
|
|
354
|
+
await this.dumpToFile(sqlite3, entry.filePath, outputPath)
|
|
355
|
+
} else {
|
|
356
|
+
// Binary copy for 'dump' format
|
|
357
|
+
await copyFile(entry.filePath, outputPath)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const stats = statSync(outputPath)
|
|
361
|
+
return {
|
|
362
|
+
path: outputPath,
|
|
363
|
+
format: options.format,
|
|
364
|
+
size: stats.size,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Restore a backup to the SQLite database
|
|
370
|
+
*/
|
|
371
|
+
async restore(
|
|
372
|
+
container: ContainerConfig,
|
|
373
|
+
backupPath: string,
|
|
374
|
+
_options?: Record<string, unknown>,
|
|
375
|
+
): Promise<RestoreResult> {
|
|
376
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
377
|
+
if (!entry) {
|
|
378
|
+
throw new Error(`Container "${container.name}" not registered`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const format = await this.detectBackupFormat(backupPath)
|
|
382
|
+
|
|
383
|
+
if (format.format === 'sql') {
|
|
384
|
+
// Restore SQL dump
|
|
385
|
+
const sqlite3 = await this.getSqlite3Path()
|
|
386
|
+
if (!sqlite3) {
|
|
387
|
+
throw new Error('sqlite3 not found')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Pipe file to sqlite3 stdin (avoids shell injection)
|
|
391
|
+
await this.runSqlFile(sqlite3, entry.filePath, backupPath)
|
|
392
|
+
return { format: 'sql' }
|
|
393
|
+
} else {
|
|
394
|
+
// Binary file copy
|
|
395
|
+
await copyFile(backupPath, entry.filePath)
|
|
396
|
+
return { format: 'sqlite' }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Create a dump from a SQLite file (for clone operations)
|
|
402
|
+
* Supports:
|
|
403
|
+
* - Local file paths: ./mydb.sqlite, /path/to/db.sqlite
|
|
404
|
+
* - Local sqlite:// URLs: sqlite:///path/to/db.sqlite
|
|
405
|
+
* - Remote HTTP/HTTPS: https://example.com/backup.sqlite
|
|
406
|
+
*/
|
|
407
|
+
async dumpFromConnectionString(
|
|
408
|
+
connectionString: string,
|
|
409
|
+
outputPath: string,
|
|
410
|
+
): Promise<DumpResult> {
|
|
411
|
+
let filePath = connectionString
|
|
412
|
+
let tempFile: string | null = null
|
|
413
|
+
|
|
414
|
+
// Handle HTTP/HTTPS URLs - download to temp file
|
|
415
|
+
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
|
416
|
+
tempFile = join(tmpdir(), `spindb-download-${Date.now()}.sqlite`)
|
|
417
|
+
await this.downloadFile(filePath, tempFile)
|
|
418
|
+
|
|
419
|
+
// Validate it's a valid SQLite database
|
|
420
|
+
if (!(await this.isValidSqliteFile(tempFile))) {
|
|
421
|
+
await unlink(tempFile)
|
|
422
|
+
throw new Error('Downloaded file is not a valid SQLite database')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
filePath = tempFile
|
|
426
|
+
}
|
|
427
|
+
// Handle sqlite:// URLs (strip prefix for local file)
|
|
428
|
+
else if (filePath.startsWith('sqlite:///')) {
|
|
429
|
+
filePath = filePath.slice('sqlite:///'.length)
|
|
430
|
+
} else if (filePath.startsWith('sqlite://')) {
|
|
431
|
+
filePath = filePath.slice('sqlite://'.length)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Verify local file exists
|
|
435
|
+
if (!existsSync(filePath)) {
|
|
436
|
+
throw new Error(`SQLite database file not found: ${filePath}`)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const sqlite3 = await this.getSqlite3Path()
|
|
440
|
+
if (!sqlite3) {
|
|
441
|
+
throw new Error('sqlite3 not found')
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Pipe .dump output to file (avoids shell injection)
|
|
445
|
+
await this.dumpToFile(sqlite3, filePath, outputPath)
|
|
446
|
+
|
|
447
|
+
// Clean up temp file if we downloaded it
|
|
448
|
+
if (tempFile && existsSync(tempFile)) {
|
|
449
|
+
await unlink(tempFile)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { filePath: outputPath }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Dump SQLite database to a file using spawn (avoids shell injection)
|
|
457
|
+
* Equivalent to: sqlite3 dbPath .dump > outputPath
|
|
458
|
+
*/
|
|
459
|
+
private async dumpToFile(
|
|
460
|
+
sqlite3Path: string,
|
|
461
|
+
dbPath: string,
|
|
462
|
+
outputPath: string,
|
|
463
|
+
): Promise<void> {
|
|
464
|
+
return new Promise((resolve, reject) => {
|
|
465
|
+
const output = createWriteStream(outputPath)
|
|
466
|
+
const proc = spawn(sqlite3Path, [dbPath, '.dump'])
|
|
467
|
+
|
|
468
|
+
proc.stdout.pipe(output)
|
|
469
|
+
|
|
470
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
471
|
+
// Collect stderr but don't fail immediately - sqlite3 may write warnings
|
|
472
|
+
console.error(data.toString())
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
proc.on('error', (err) => {
|
|
476
|
+
output.close()
|
|
477
|
+
reject(err)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
proc.on('close', (code) => {
|
|
481
|
+
output.close()
|
|
482
|
+
if (code === 0) {
|
|
483
|
+
resolve()
|
|
484
|
+
} else {
|
|
485
|
+
reject(new Error(`sqlite3 dump failed with exit code ${code}`))
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Run a SQL file against SQLite database using spawn (avoids shell injection)
|
|
493
|
+
* Equivalent to: sqlite3 dbPath < sqlFilePath
|
|
494
|
+
*/
|
|
495
|
+
private async runSqlFile(
|
|
496
|
+
sqlite3Path: string,
|
|
497
|
+
dbPath: string,
|
|
498
|
+
sqlFilePath: string,
|
|
499
|
+
): Promise<void> {
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
const input = createReadStream(sqlFilePath)
|
|
502
|
+
const proc = spawn(sqlite3Path, [dbPath])
|
|
503
|
+
|
|
504
|
+
input.pipe(proc.stdin)
|
|
505
|
+
|
|
506
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
507
|
+
// Collect stderr but don't fail immediately - sqlite3 may write warnings
|
|
508
|
+
console.error(data.toString())
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
input.on('error', (err) => {
|
|
512
|
+
proc.kill()
|
|
513
|
+
reject(err)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
proc.on('error', (err) => {
|
|
517
|
+
reject(err)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
proc.on('close', (code) => {
|
|
521
|
+
if (code === 0) {
|
|
522
|
+
resolve()
|
|
523
|
+
} else {
|
|
524
|
+
reject(new Error(`sqlite3 script execution failed with exit code ${code}`))
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Download a file from HTTP/HTTPS URL
|
|
532
|
+
*/
|
|
533
|
+
private async downloadFile(url: string, destPath: string): Promise<void> {
|
|
534
|
+
const response = await fetch(url)
|
|
535
|
+
if (!response.ok) {
|
|
536
|
+
throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const buffer = await response.arrayBuffer()
|
|
540
|
+
await writeFile(destPath, Buffer.from(buffer))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Validate a file is a valid SQLite database
|
|
545
|
+
* SQLite files start with "SQLite format 3\0" (first 16 bytes)
|
|
546
|
+
*/
|
|
547
|
+
private async isValidSqliteFile(filePath: string): Promise<boolean> {
|
|
548
|
+
try {
|
|
549
|
+
const buffer = Buffer.alloc(16)
|
|
550
|
+
const fd = await open(filePath, 'r')
|
|
551
|
+
await fd.read(buffer, 0, 16, 0)
|
|
552
|
+
await fd.close()
|
|
553
|
+
// Check for SQLite magic header
|
|
554
|
+
return buffer.toString('utf8', 0, 15) === 'SQLite format 3'
|
|
555
|
+
} catch {
|
|
556
|
+
return false
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Run a SQL file or inline SQL statement
|
|
562
|
+
*/
|
|
563
|
+
async runScript(
|
|
564
|
+
container: ContainerConfig,
|
|
565
|
+
options: { file?: string; sql?: string; database?: string },
|
|
566
|
+
): Promise<void> {
|
|
567
|
+
const entry = await sqliteRegistry.get(container.name)
|
|
568
|
+
if (!entry || !existsSync(entry.filePath)) {
|
|
569
|
+
throw new Error('SQLite database file not found')
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const sqlite3 = await this.getSqlite3Path()
|
|
573
|
+
if (!sqlite3) {
|
|
574
|
+
throw new Error('sqlite3 not found')
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (options.file) {
|
|
578
|
+
// Run SQL file - pipe file to stdin (avoids shell injection)
|
|
579
|
+
await this.runSqlFile(sqlite3, entry.filePath, options.file)
|
|
580
|
+
} else if (options.sql) {
|
|
581
|
+
// Run inline SQL - pass as argument (avoids shell injection)
|
|
582
|
+
await execFileAsync(sqlite3, [entry.filePath, options.sql])
|
|
583
|
+
} else {
|
|
584
|
+
throw new Error('Either file or sql option must be provided')
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Get available versions - SQLite uses system version
|
|
590
|
+
*/
|
|
591
|
+
async fetchAvailableVersions(): Promise<Record<string, string[]>> {
|
|
592
|
+
// SQLite uses system version, just return supported versions
|
|
593
|
+
return { '3': ['3'] }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export const sqliteEngine = new SQLiteEngine()
|