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/cli/ui/prompts.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import inquirer from 'inquirer'
2
2
  import chalk from 'chalk'
3
3
  import ora from 'ora'
4
+ import { existsSync, statSync } from 'fs'
5
+ import { resolve, join } from 'path'
6
+ import { homedir } from 'os'
4
7
  import { listEngines, getEngine } from '../../engines'
5
8
  import { defaults, getEngineDefaults } from '../../config/defaults'
6
9
  import { installPostgresBinaries } from '../../engines/postgresql/binary-manager'
@@ -204,35 +207,69 @@ export async function promptConfirm(
204
207
 
205
208
  /**
206
209
  * Prompt for container selection from a list
210
+ * @param containers - List of containers to choose from
211
+ * @param message - Prompt message
212
+ * @param options - Optional settings
213
+ * @param options.includeBack - Include a back option (returns null when selected)
207
214
  */
208
215
  export async function promptContainerSelect(
209
216
  containers: ContainerConfig[],
210
217
  message: string = 'Select container:',
218
+ options: { includeBack?: boolean } = {},
211
219
  ): Promise<string | null> {
212
220
  if (containers.length === 0) {
213
221
  return null
214
222
  }
215
223
 
224
+ type Choice = { name: string; value: string; short?: string }
225
+ const choices: Choice[] = containers.map((c) => ({
226
+ name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine} ${c.version}, port ${c.port})`)} ${
227
+ c.status === 'running'
228
+ ? chalk.green('● running')
229
+ : chalk.gray('○ stopped')
230
+ }`,
231
+ value: c.name,
232
+ short: c.name,
233
+ }))
234
+
235
+ if (options.includeBack) {
236
+ choices.push({ name: `${chalk.blue('←')} Back`, value: '__back__' })
237
+ }
238
+
216
239
  const { container } = await inquirer.prompt<{ container: string }>([
217
240
  {
218
241
  type: 'list',
219
242
  name: 'container',
220
243
  message,
221
- choices: containers.map((c) => ({
222
- name: `${c.name} ${chalk.gray(`(${getEngineIcon(c.engine)} ${c.engine} ${c.version}, port ${c.port})`)} ${
223
- c.status === 'running'
224
- ? chalk.green('● running')
225
- : chalk.gray('○ stopped')
226
- }`,
227
- value: c.name,
228
- short: c.name,
229
- })),
244
+ choices,
230
245
  },
231
246
  ])
232
247
 
248
+ if (container === '__back__') {
249
+ return null
250
+ }
251
+
233
252
  return container
234
253
  }
235
254
 
255
+ /**
256
+ * Sanitize a string to be a valid database name
257
+ * Replaces invalid characters with underscores
258
+ */
259
+ function sanitizeDatabaseName(name: string): string {
260
+ // Replace invalid characters with underscores
261
+ let sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
262
+ // Ensure it starts with a letter or underscore
263
+ if (sanitized && !/^[a-zA-Z_]/.test(sanitized)) {
264
+ sanitized = '_' + sanitized
265
+ }
266
+ // Collapse multiple underscores
267
+ sanitized = sanitized.replace(/_+/g, '_')
268
+ // Trim trailing underscores
269
+ sanitized = sanitized.replace(/_+$/, '')
270
+ return sanitized
271
+ }
272
+
236
273
  /**
237
274
  * Prompt for database name
238
275
  * @param defaultName - Default value for the database name
@@ -246,12 +283,15 @@ export async function promptDatabaseName(
246
283
  const label =
247
284
  engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
248
285
 
286
+ // Sanitize the default name to ensure it's valid
287
+ const sanitizedDefault = defaultName ? sanitizeDatabaseName(defaultName) : undefined
288
+
249
289
  const { database } = await inquirer.prompt<{ database: string }>([
250
290
  {
251
291
  type: 'input',
252
292
  name: 'database',
253
293
  message: label,
254
- default: defaultName,
294
+ default: sanitizedDefault,
255
295
  validate: (input: string) => {
256
296
  if (!input) return 'Database name is required'
257
297
  // PostgreSQL database naming rules (also valid for MySQL)
@@ -362,6 +402,111 @@ export type CreateOptions = {
362
402
  version: string
363
403
  port: number
364
404
  database: string
405
+ path?: string // SQLite file path
406
+ }
407
+
408
+ /**
409
+ * Prompt for SQLite database file location
410
+ * Similar to the relocate logic in container-handlers.ts
411
+ */
412
+ export async function promptSqlitePath(
413
+ containerName: string,
414
+ ): Promise<string | undefined> {
415
+ const defaultPath = `./${containerName}.sqlite`
416
+
417
+ console.log(chalk.gray(' SQLite databases are stored as files in your project directory.'))
418
+ console.log(chalk.gray(` Default: ${defaultPath}`))
419
+ console.log()
420
+
421
+ const { useDefault } = await inquirer.prompt<{ useDefault: string }>([
422
+ {
423
+ type: 'list',
424
+ name: 'useDefault',
425
+ message: 'Where should the database file be created?',
426
+ choices: [
427
+ { name: `Use default location (${defaultPath})`, value: 'default' },
428
+ { name: 'Specify custom path', value: 'custom' },
429
+ ],
430
+ },
431
+ ])
432
+
433
+ if (useDefault === 'default') {
434
+ return undefined // Use default
435
+ }
436
+
437
+ const { inputPath } = await inquirer.prompt<{ inputPath: string }>([
438
+ {
439
+ type: 'input',
440
+ name: 'inputPath',
441
+ message: 'File path:',
442
+ default: defaultPath,
443
+ validate: (input: string) => {
444
+ if (!input) return 'Path is required'
445
+ return true
446
+ },
447
+ },
448
+ ])
449
+
450
+ // Expand ~ to home directory
451
+ let expandedPath = inputPath
452
+ if (inputPath === '~') {
453
+ expandedPath = homedir()
454
+ } else if (inputPath.startsWith('~/')) {
455
+ expandedPath = join(homedir(), inputPath.slice(2))
456
+ }
457
+
458
+ // Convert relative paths to absolute
459
+ if (!expandedPath.startsWith('/')) {
460
+ expandedPath = resolve(process.cwd(), expandedPath)
461
+ }
462
+
463
+ // Check if path looks like a file (has db extension) or directory
464
+ const hasDbExtension = /\.(sqlite3?|db)$/i.test(expandedPath)
465
+
466
+ // Treat as directory if:
467
+ // - ends with /
468
+ // - exists and is a directory
469
+ // - doesn't have a database file extension (assume it's a directory path)
470
+ const isDirectory =
471
+ expandedPath.endsWith('/') ||
472
+ (existsSync(expandedPath) && statSync(expandedPath).isDirectory()) ||
473
+ !hasDbExtension
474
+
475
+ let finalPath: string
476
+ if (isDirectory) {
477
+ // Remove trailing slash if present, then append filename
478
+ const dirPath = expandedPath.endsWith('/')
479
+ ? expandedPath.slice(0, -1)
480
+ : expandedPath
481
+ finalPath = join(dirPath, `${containerName}.sqlite`)
482
+ } else {
483
+ finalPath = expandedPath
484
+ }
485
+
486
+ // Check if file already exists
487
+ if (existsSync(finalPath)) {
488
+ console.log(chalk.yellow(` Warning: File already exists: ${finalPath}`))
489
+ const { overwrite } = await inquirer.prompt<{ overwrite: string }>([
490
+ {
491
+ type: 'list',
492
+ name: 'overwrite',
493
+ message: 'A file already exists at this location. What would you like to do?',
494
+ choices: [
495
+ { name: 'Choose a different path', value: 'different' },
496
+ { name: 'Cancel', value: 'cancel' },
497
+ ],
498
+ },
499
+ ])
500
+
501
+ if (overwrite === 'cancel') {
502
+ throw new Error('Creation cancelled')
503
+ }
504
+
505
+ // Recursively prompt again
506
+ return promptSqlitePath(containerName)
507
+ }
508
+
509
+ return finalPath
365
510
  }
366
511
 
367
512
  /**
@@ -375,11 +520,17 @@ export async function promptCreateOptions(): Promise<CreateOptions> {
375
520
  const name = await promptContainerName()
376
521
  const database = await promptDatabaseName(name, engine) // Default to container name
377
522
 
378
- // Get engine-specific default port
379
- const engineDefaults = getEngineDefaults(engine)
380
- const port = await promptPort(engineDefaults.defaultPort)
523
+ // SQLite is file-based, no port needed but needs path
524
+ let port = 0
525
+ let path: string | undefined
526
+ if (engine === 'sqlite') {
527
+ path = await promptSqlitePath(name)
528
+ } else {
529
+ const engineDefaults = getEngineDefaults(engine)
530
+ port = await promptPort(engineDefaults.defaultPort)
531
+ }
381
532
 
382
- return { name, engine, version, port, database }
533
+ return { name, engine, version, port, database, path }
383
534
  }
384
535
 
385
536
  /**
@@ -59,6 +59,20 @@ export const engineDefaults: Record<string, EngineDefaults> = {
59
59
  clientTools: ['mysql', 'mysqldump', 'mysqlpump'],
60
60
  maxConnections: 200, // Higher than default 151 for parallel builds
61
61
  },
62
+ sqlite: {
63
+ defaultVersion: '3',
64
+ defaultPort: 0, // File-based, no port
65
+ portRange: { start: 0, end: 0 }, // N/A
66
+ supportedVersions: ['3'],
67
+ latestVersion: '3',
68
+ superuser: '', // No authentication
69
+ connectionScheme: 'sqlite',
70
+ logFileName: '', // No log file
71
+ pidFileName: '', // No PID file (no server process)
72
+ dataSubdir: '', // File is the data
73
+ clientTools: ['sqlite3'],
74
+ maxConnections: 0, // N/A - file-based
75
+ },
62
76
  }
63
77
 
64
78
  /**
@@ -289,6 +289,42 @@ const mysqlDependencies: EngineDependencies = {
289
289
  ],
290
290
  }
291
291
 
292
+ // =============================================================================
293
+ // SQLite Dependencies
294
+ // =============================================================================
295
+
296
+ const sqliteDependencies: EngineDependencies = {
297
+ engine: 'sqlite',
298
+ displayName: 'SQLite',
299
+ dependencies: [
300
+ {
301
+ name: 'sqlite3',
302
+ binary: 'sqlite3',
303
+ description: 'SQLite command-line interface',
304
+ packages: {
305
+ brew: { package: 'sqlite' },
306
+ apt: { package: 'sqlite3' },
307
+ yum: { package: 'sqlite' },
308
+ dnf: { package: 'sqlite' },
309
+ pacman: { package: 'sqlite' },
310
+ },
311
+ manualInstall: {
312
+ darwin: [
313
+ 'Install Homebrew: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
314
+ 'Then run: brew install sqlite',
315
+ 'Note: macOS includes sqlite3 by default in /usr/bin/sqlite3',
316
+ ],
317
+ linux: [
318
+ 'Debian/Ubuntu: sudo apt install sqlite3',
319
+ 'CentOS/RHEL: sudo yum install sqlite',
320
+ 'Fedora: sudo dnf install sqlite',
321
+ 'Arch: sudo pacman -S sqlite',
322
+ ],
323
+ },
324
+ },
325
+ ],
326
+ }
327
+
292
328
  // =============================================================================
293
329
  // Optional Tools (engine-agnostic)
294
330
  // =============================================================================
@@ -381,6 +417,35 @@ export const mycliDependency: Dependency = {
381
417
  },
382
418
  }
383
419
 
420
+ /**
421
+ * litecli - SQLite CLI with auto-completion and syntax highlighting
422
+ * https://github.com/dbcli/litecli
423
+ */
424
+ export const litecliDependency: Dependency = {
425
+ name: 'litecli',
426
+ binary: 'litecli',
427
+ description:
428
+ 'SQLite CLI with intelligent auto-completion and syntax highlighting',
429
+ packages: {
430
+ brew: { package: 'litecli' },
431
+ apt: { package: 'litecli' },
432
+ dnf: { package: 'litecli' },
433
+ yum: { package: 'litecli' },
434
+ pacman: { package: 'litecli' },
435
+ },
436
+ manualInstall: {
437
+ darwin: [
438
+ 'Install with Homebrew: brew install litecli',
439
+ 'Or with pip: pip install litecli',
440
+ ],
441
+ linux: [
442
+ 'Debian/Ubuntu: sudo apt install litecli',
443
+ 'Fedora: sudo dnf install litecli',
444
+ 'Or with pip: pip install litecli',
445
+ ],
446
+ },
447
+ }
448
+
384
449
  // =============================================================================
385
450
  // Registry
386
451
  // =============================================================================
@@ -391,6 +456,7 @@ export const mycliDependency: Dependency = {
391
456
  export const engineDependencies: EngineDependencies[] = [
392
457
  postgresqlDependencies,
393
458
  mysqlDependencies,
459
+ sqliteDependencies,
394
460
  ]
395
461
 
396
462
  /**
package/config/paths.ts CHANGED
@@ -114,4 +114,12 @@ export const paths = {
114
114
  getEngineContainersPath(engine: string): string {
115
115
  return join(this.containers, engine)
116
116
  },
117
+
118
+ /**
119
+ * Get path for SQLite registry file
120
+ * SQLite uses a registry (not container directories) since databases are stored externally
121
+ */
122
+ getSqliteRegistryPath(): string {
123
+ return join(this.root, 'sqlite-registry.json')
124
+ },
117
125
  }
@@ -1,10 +1,11 @@
1
1
  import { existsSync } from 'fs'
2
- import { mkdir, readdir, readFile, writeFile, rm, cp } from 'fs/promises'
2
+ import { mkdir, readdir, readFile, writeFile, rm, cp, unlink } from 'fs/promises'
3
3
  import { paths } from '../config/paths'
4
4
  import { processManager } from './process-manager'
5
5
  import { portManager } from './port-manager'
6
6
  import { getEngineDefaults, getSupportedEngines } from '../config/defaults'
7
7
  import { getEngine } from '../engines'
8
+ import { sqliteRegistry } from '../engines/sqlite/registry'
8
9
  import type { ContainerConfig } from '../types'
9
10
  import { Engine } from '../types'
10
11
 
@@ -74,6 +75,11 @@ export class ContainerManager {
74
75
  const { engine } = options || {}
75
76
 
76
77
  if (engine) {
78
+ // SQLite uses registry instead of filesystem
79
+ if (engine === 'sqlite') {
80
+ return this.getSqliteConfig(name)
81
+ }
82
+
77
83
  // Look in specific engine directory
78
84
  const configPath = paths.getContainerConfigPath(name, { engine })
79
85
  if (!existsSync(configPath)) {
@@ -84,8 +90,14 @@ export class ContainerManager {
84
90
  return this.migrateConfig(config)
85
91
  }
86
92
 
87
- // Search all engine directories
88
- const engines = getSupportedEngines()
93
+ // Search SQLite registry first
94
+ const sqliteConfig = await this.getSqliteConfig(name)
95
+ if (sqliteConfig) {
96
+ return sqliteConfig
97
+ }
98
+
99
+ // Search all engine directories (excluding sqlite which uses registry)
100
+ const engines = getSupportedEngines().filter((e) => e !== 'sqlite')
89
101
  for (const eng of engines) {
90
102
  const configPath = paths.getContainerConfigPath(name, { engine: eng })
91
103
  if (existsSync(configPath)) {
@@ -98,6 +110,29 @@ export class ContainerManager {
98
110
  return null
99
111
  }
100
112
 
113
+ /**
114
+ * Get SQLite container config from registry
115
+ */
116
+ private async getSqliteConfig(name: string): Promise<ContainerConfig | null> {
117
+ const entry = await sqliteRegistry.get(name)
118
+ if (!entry) {
119
+ return null
120
+ }
121
+
122
+ // Convert registry entry to ContainerConfig format
123
+ const fileExists = existsSync(entry.filePath)
124
+ return {
125
+ name: entry.name,
126
+ engine: Engine.SQLite,
127
+ version: '3',
128
+ port: 0,
129
+ database: entry.filePath, // For SQLite, database field stores file path
130
+ databases: [entry.filePath],
131
+ created: entry.created,
132
+ status: fileExists ? 'running' : 'stopped', // "running" = file exists
133
+ }
134
+ }
135
+
101
136
  /**
102
137
  * Migrate old container configs to include databases array
103
138
  * Ensures primary database is always in the databases array
@@ -165,12 +200,21 @@ export class ContainerManager {
165
200
  const { engine } = options || {}
166
201
 
167
202
  if (engine) {
203
+ // SQLite uses registry
204
+ if (engine === 'sqlite') {
205
+ return sqliteRegistry.exists(name)
206
+ }
168
207
  const configPath = paths.getContainerConfigPath(name, { engine })
169
208
  return existsSync(configPath)
170
209
  }
171
210
 
172
- // Check all engine directories
173
- const engines = getSupportedEngines()
211
+ // Check SQLite registry first
212
+ if (await sqliteRegistry.exists(name)) {
213
+ return true
214
+ }
215
+
216
+ // Check all engine directories (excluding sqlite)
217
+ const engines = getSupportedEngines().filter((e) => e !== 'sqlite')
174
218
  for (const eng of engines) {
175
219
  const configPath = paths.getContainerConfigPath(name, { engine: eng })
176
220
  if (existsSync(configPath)) {
@@ -185,14 +229,31 @@ export class ContainerManager {
185
229
  * List all containers across all engines
186
230
  */
187
231
  async list(): Promise<ContainerConfig[]> {
188
- const containersDir = paths.containers
232
+ const containers: ContainerConfig[] = []
189
233
 
234
+ // List SQLite containers from registry
235
+ const sqliteEntries = await sqliteRegistry.list()
236
+ for (const entry of sqliteEntries) {
237
+ const fileExists = existsSync(entry.filePath)
238
+ containers.push({
239
+ name: entry.name,
240
+ engine: Engine.SQLite,
241
+ version: '3',
242
+ port: 0,
243
+ database: entry.filePath,
244
+ databases: [entry.filePath],
245
+ created: entry.created,
246
+ status: fileExists ? 'running' : 'stopped', // "running" = file exists
247
+ })
248
+ }
249
+
250
+ // List server-based containers (PostgreSQL, MySQL)
251
+ const containersDir = paths.containers
190
252
  if (!existsSync(containersDir)) {
191
- return []
253
+ return containers
192
254
  }
193
255
 
194
- const containers: ContainerConfig[] = []
195
- const engines = getSupportedEngines()
256
+ const engines = getSupportedEngines().filter((e) => e !== 'sqlite')
196
257
 
197
258
  for (const engine of engines) {
198
259
  const engineDir = paths.getEngineContainersPath(engine)
@@ -236,7 +297,23 @@ export class ContainerManager {
236
297
 
237
298
  const { engine } = config
238
299
 
239
- // Check if running
300
+ // SQLite: delete file, remove from registry, and clean up container directory
301
+ if (engine === Engine.SQLite) {
302
+ const entry = await sqliteRegistry.get(name)
303
+ if (entry && existsSync(entry.filePath)) {
304
+ await unlink(entry.filePath)
305
+ }
306
+ await sqliteRegistry.remove(name)
307
+
308
+ // Also remove the container directory (created by containerManager.create)
309
+ const containerPath = paths.getContainerPath(name, { engine })
310
+ if (existsSync(containerPath)) {
311
+ await rm(containerPath, { recursive: true, force: true })
312
+ }
313
+ return
314
+ }
315
+
316
+ // Server databases: check if running first
240
317
  const running = await processManager.isRunning(name, { engine })
241
318
  if (running && !force) {
242
319
  throw new Error(
@@ -335,7 +412,38 @@ export class ContainerManager {
335
412
  throw new Error(`Container "${newName}" already exists`)
336
413
  }
337
414
 
338
- // Check container is not running
415
+ // SQLite: rename in registry and handle container directory
416
+ if (engine === Engine.SQLite) {
417
+ const entry = await sqliteRegistry.get(oldName)
418
+ if (!entry) {
419
+ throw new Error(`SQLite container "${oldName}" not found in registry`)
420
+ }
421
+
422
+ // Remove old entry and add new one with updated name
423
+ await sqliteRegistry.remove(oldName)
424
+ await sqliteRegistry.add({
425
+ name: newName,
426
+ filePath: entry.filePath,
427
+ created: entry.created,
428
+ lastVerified: entry.lastVerified,
429
+ })
430
+
431
+ // Rename container directory if it exists (created by containerManager.create)
432
+ const oldContainerPath = paths.getContainerPath(oldName, { engine })
433
+ const newContainerPath = paths.getContainerPath(newName, { engine })
434
+ if (existsSync(oldContainerPath)) {
435
+ await cp(oldContainerPath, newContainerPath, { recursive: true })
436
+ await rm(oldContainerPath, { recursive: true, force: true })
437
+ }
438
+
439
+ // Return updated config
440
+ return {
441
+ ...sourceConfig,
442
+ name: newName,
443
+ }
444
+ }
445
+
446
+ // Server databases: check container is not running
339
447
  const running = await processManager.isRunning(oldName, { engine })
340
448
  if (running) {
341
449
  throw new Error(`Container "${oldName}" is running. Stop it first`)
@@ -18,6 +18,7 @@ import {
18
18
  usqlDependency,
19
19
  pgcliDependency,
20
20
  mycliDependency,
21
+ litecliDependency,
21
22
  } from '../config/os-dependencies'
22
23
  import { platformService } from './platform-service'
23
24
  import { configManager } from './config-manager'
@@ -398,3 +399,20 @@ export function getMycliManualInstructions(
398
399
  ): string[] {
399
400
  return getManualInstallInstructions(mycliDependency, platform)
400
401
  }
402
+
403
+ export async function isLitecliInstalled(): Promise<boolean> {
404
+ const status = await checkDependency(litecliDependency)
405
+ return status.installed
406
+ }
407
+
408
+ export async function installLitecli(
409
+ packageManager: DetectedPackageManager,
410
+ ): Promise<InstallResult> {
411
+ return installDependency(litecliDependency, packageManager)
412
+ }
413
+
414
+ export function getLitecliManualInstructions(
415
+ platform: Platform = getCurrentPlatform(),
416
+ ): string[] {
417
+ return getManualInstallInstructions(litecliDependency, platform)
418
+ }
package/engines/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { postgresqlEngine } from './postgresql'
2
2
  import { mysqlEngine } from './mysql'
3
+ import { sqliteEngine } from './sqlite'
3
4
  import type { BaseEngine } from './base-engine'
4
5
  import type { EngineInfo } from '../types'
5
6
 
@@ -14,6 +15,9 @@ export const engines: Record<string, BaseEngine> = {
14
15
  // MySQL and aliases
15
16
  mysql: mysqlEngine,
16
17
  mariadb: mysqlEngine, // MariaDB is MySQL-compatible
18
+ // SQLite and aliases
19
+ sqlite: sqliteEngine,
20
+ lite: sqliteEngine,
17
21
  }
18
22
 
19
23
  /**