spindb 0.9.1 → 0.9.3

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.
Files changed (55) hide show
  1. package/README.md +5 -8
  2. package/cli/commands/attach.ts +108 -0
  3. package/cli/commands/backup.ts +13 -11
  4. package/cli/commands/clone.ts +14 -10
  5. package/cli/commands/config.ts +29 -29
  6. package/cli/commands/connect.ts +51 -39
  7. package/cli/commands/create.ts +65 -32
  8. package/cli/commands/delete.ts +8 -8
  9. package/cli/commands/deps.ts +17 -15
  10. package/cli/commands/detach.ts +100 -0
  11. package/cli/commands/doctor.ts +27 -13
  12. package/cli/commands/edit.ts +120 -57
  13. package/cli/commands/engines.ts +17 -15
  14. package/cli/commands/info.ts +8 -6
  15. package/cli/commands/list.ts +127 -18
  16. package/cli/commands/logs.ts +15 -11
  17. package/cli/commands/menu/backup-handlers.ts +52 -47
  18. package/cli/commands/menu/container-handlers.ts +164 -79
  19. package/cli/commands/menu/engine-handlers.ts +21 -11
  20. package/cli/commands/menu/index.ts +4 -4
  21. package/cli/commands/menu/shell-handlers.ts +34 -31
  22. package/cli/commands/menu/sql-handlers.ts +22 -16
  23. package/cli/commands/menu/update-handlers.ts +19 -17
  24. package/cli/commands/restore.ts +22 -20
  25. package/cli/commands/run.ts +20 -18
  26. package/cli/commands/self-update.ts +5 -5
  27. package/cli/commands/sqlite.ts +247 -0
  28. package/cli/commands/start.ts +11 -9
  29. package/cli/commands/stop.ts +9 -9
  30. package/cli/commands/url.ts +12 -9
  31. package/cli/helpers.ts +9 -4
  32. package/cli/index.ts +6 -0
  33. package/cli/ui/prompts.ts +12 -5
  34. package/cli/ui/spinner.ts +4 -4
  35. package/cli/ui/theme.ts +4 -4
  36. package/config/paths.ts +0 -8
  37. package/core/binary-manager.ts +5 -1
  38. package/core/config-manager.ts +32 -0
  39. package/core/container-manager.ts +5 -5
  40. package/core/platform-service.ts +3 -3
  41. package/core/start-with-retry.ts +6 -6
  42. package/core/transaction-manager.ts +6 -6
  43. package/engines/mysql/backup.ts +37 -13
  44. package/engines/mysql/index.ts +11 -11
  45. package/engines/mysql/restore.ts +4 -4
  46. package/engines/mysql/version-validator.ts +2 -2
  47. package/engines/postgresql/binary-manager.ts +17 -17
  48. package/engines/postgresql/index.ts +7 -2
  49. package/engines/postgresql/restore.ts +2 -2
  50. package/engines/postgresql/version-validator.ts +2 -2
  51. package/engines/sqlite/index.ts +30 -15
  52. package/engines/sqlite/registry.ts +64 -33
  53. package/engines/sqlite/scanner.ts +99 -0
  54. package/package.json +4 -3
  55. package/types/index.ts +21 -1
@@ -2,7 +2,7 @@ import { exec } from 'child_process'
2
2
  import { promisify } from 'util'
3
3
  import chalk from 'chalk'
4
4
  import { createSpinner } from '../../cli/ui/spinner'
5
- import { warning, error as themeError, success } from '../../cli/ui/theme'
5
+ import { uiWarning, uiError, uiSuccess } from '../../cli/ui/theme'
6
6
  import {
7
7
  detectPackageManager as detectPM,
8
8
  installEngineDependencies,
@@ -311,7 +311,7 @@ export async function installPostgresBinaries(): Promise<boolean> {
311
311
  const packageManager = await detectPM()
312
312
  if (!packageManager) {
313
313
  spinner.fail('No supported package manager found')
314
- console.log(themeError('Please install PostgreSQL client tools manually:'))
314
+ console.log(uiError('Please install PostgreSQL client tools manually:'))
315
315
 
316
316
  // Show platform-specific instructions from the registry
317
317
  const platform = getCurrentPlatform()
@@ -348,21 +348,21 @@ export async function installPostgresBinaries(): Promise<boolean> {
348
348
 
349
349
  if (allSuccess) {
350
350
  console.log()
351
- console.log(success('PostgreSQL client tools installed successfully'))
351
+ console.log(uiSuccess('PostgreSQL client tools installed successfully'))
352
352
  return true
353
353
  } else {
354
354
  const failed = results.filter((r) => !r.success)
355
355
  console.log()
356
- console.log(themeError('Some installations failed:'))
356
+ console.log(uiError('Some installations failed:'))
357
357
  for (const f of failed) {
358
- console.log(themeError(` ${f.dependency.name}: ${f.error}`))
358
+ console.log(uiError(` ${f.dependency.name}: ${f.error}`))
359
359
  }
360
360
  return false
361
361
  }
362
362
  } catch (error: unknown) {
363
363
  console.log()
364
- console.log(themeError('Failed to install PostgreSQL client tools'))
365
- console.log(warning('Please install manually'))
364
+ console.log(uiError('Failed to install PostgreSQL client tools'))
365
+ console.log(uiWarning('Please install manually'))
366
366
  if (error instanceof Error) {
367
367
  console.log(chalk.gray(`Error details: ${error.message}`))
368
368
  }
@@ -408,7 +408,7 @@ export async function updatePostgresClientTools(): Promise<boolean> {
408
408
 
409
409
  spinner.succeed('PostgreSQL client tools updated')
410
410
  console.log(
411
- success(
411
+ uiSuccess(
412
412
  `Client tools successfully linked to PostgreSQL ${latestMajor}`,
413
413
  ),
414
414
  )
@@ -418,13 +418,13 @@ export async function updatePostgresClientTools(): Promise<boolean> {
418
418
  // For other package managers, use the standard update
419
419
  await execWithTimeout(packageManager.updateCommand('postgresql'), 120000)
420
420
  spinner.succeed('PostgreSQL client tools updated')
421
- console.log(success('Update completed successfully'))
421
+ console.log(uiSuccess('Update completed successfully'))
422
422
  return true
423
423
  }
424
424
  } catch (error: unknown) {
425
425
  spinner.fail('Update failed')
426
- console.log(themeError('Failed to update PostgreSQL client tools'))
427
- console.log(warning('Please update manually:'))
426
+ console.log(uiError('Failed to update PostgreSQL client tools'))
427
+ console.log(uiWarning('Please update manually:'))
428
428
 
429
429
  if (packageManager.name === 'brew') {
430
430
  const olderVersions = ['14', '15', '16'].filter((v) => v !== latestMajor)
@@ -473,12 +473,12 @@ export async function updatePostgresBinaries(): Promise<boolean> {
473
473
  try {
474
474
  await execWithTimeout(packageManager.updateCommand('postgresql'), 120000) // 2 minute timeout
475
475
  updateSpinner.succeed('PostgreSQL client tools updated')
476
- console.log(success('Update completed successfully'))
476
+ console.log(uiSuccess('Update completed successfully'))
477
477
  return true
478
478
  } catch (error: unknown) {
479
479
  updateSpinner.fail('Update failed')
480
- console.log(themeError('Failed to update PostgreSQL client tools'))
481
- console.log(warning('Please update manually:'))
480
+ console.log(uiError('Failed to update PostgreSQL client tools'))
481
+ console.log(uiWarning('Please update manually:'))
482
482
  console.log(` ${packageManager.updateCommand('postgresql')}`)
483
483
  if (error instanceof Error) {
484
484
  console.log(chalk.gray(`Error details: ${error.message}`))
@@ -505,7 +505,7 @@ export async function ensurePostgresBinary(
505
505
  return { success: false, info: null, action: 'install_required' }
506
506
  }
507
507
 
508
- console.log(warning(`${binary} not found on your system`))
508
+ console.log(uiWarning(`${binary} not found on your system`))
509
509
  const success = await installPostgresBinaries()
510
510
  if (!success) {
511
511
  return { success: false, info: null, action: 'install_failed' }
@@ -527,13 +527,13 @@ export async function ensurePostgresBinary(
527
527
  }
528
528
 
529
529
  console.log(
530
- warning(
530
+ uiWarning(
531
531
  `Your ${binary} version (${info.version}) is incompatible with the dump file`,
532
532
  ),
533
533
  )
534
534
  if (info.requiredVersion) {
535
535
  console.log(
536
- warning(`Required version: ${info.requiredVersion} or compatible`),
536
+ uiWarning(`Required version: ${info.requiredVersion} or compatible`),
537
537
  )
538
538
  }
539
539
 
@@ -155,8 +155,13 @@ export class PostgreSQLEngine extends BaseEngine {
155
155
 
156
156
  // Configure max_connections after initdb creates postgresql.conf
157
157
  const maxConnections =
158
- (options.maxConnections as number) || getEngineDefaults('postgresql').maxConnections
159
- await this.setConfigValue(dataDir, 'max_connections', String(maxConnections))
158
+ (options.maxConnections as number) ||
159
+ getEngineDefaults('postgresql').maxConnections
160
+ await this.setConfigValue(
161
+ dataDir,
162
+ 'max_connections',
163
+ String(maxConnections),
164
+ )
160
165
 
161
166
  return dataDir
162
167
  }
@@ -219,8 +219,8 @@ export async function restoreBackup(
219
219
  format: detectedFormat,
220
220
  ...result,
221
221
  }
222
- } catch (err) {
223
- const e = err as Error & { stdout?: string; stderr?: string }
222
+ } catch (error) {
223
+ const e = error as Error & { stdout?: string; stderr?: string }
224
224
  // pg_restore often returns non-zero even on partial success
225
225
  return {
226
226
  format: detectedFormat,
@@ -135,11 +135,11 @@ export async function parseDumpVersion(
135
135
  }
136
136
  }
137
137
  }
138
- } catch (err) {
138
+ } catch (error) {
139
139
  logDebug('Failed to parse dump version', {
140
140
  dumpPath,
141
141
  format,
142
- error: err instanceof Error ? err.message : String(err),
142
+ error: error instanceof Error ? error.message : String(error),
143
143
  })
144
144
  }
145
145
 
@@ -446,15 +446,17 @@ export class SQLiteEngine extends BaseEngine {
446
446
  throw new Error('sqlite3 not found')
447
447
  }
448
448
 
449
- // Pipe .dump output to file (avoids shell injection)
450
- await this.dumpToFile(sqlite3, filePath, outputPath)
449
+ try {
450
+ // Pipe .dump output to file (avoids shell injection)
451
+ await this.dumpToFile(sqlite3, filePath, outputPath)
451
452
 
452
- // Clean up temp file if we downloaded it
453
- if (tempFile && existsSync(tempFile)) {
454
- await unlink(tempFile)
453
+ return { filePath: outputPath }
454
+ } finally {
455
+ // Clean up temp file if we downloaded it (even on error)
456
+ if (tempFile && existsSync(tempFile)) {
457
+ await unlink(tempFile)
458
+ }
455
459
  }
456
-
457
- return { filePath: outputPath }
458
460
  }
459
461
 
460
462
  /**
@@ -538,15 +540,28 @@ export class SQLiteEngine extends BaseEngine {
538
540
  * Download a file from HTTP/HTTPS URL
539
541
  */
540
542
  private async downloadFile(url: string, destPath: string): Promise<void> {
541
- const response = await fetch(url)
542
- if (!response.ok) {
543
- throw new Error(
544
- `Failed to download: ${response.status} ${response.statusText}`,
545
- )
546
- }
543
+ const controller = new AbortController()
544
+ const timeoutMs = 5 * 60 * 1000 // 5 minutes
545
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
546
+
547
+ try {
548
+ const response = await fetch(url, { signal: controller.signal })
549
+ if (!response.ok) {
550
+ throw new Error(
551
+ `Failed to download: ${response.status} ${response.statusText}`,
552
+ )
553
+ }
547
554
 
548
- const buffer = await response.arrayBuffer()
549
- await writeFile(destPath, Buffer.from(buffer))
555
+ const buffer = await response.arrayBuffer()
556
+ await writeFile(destPath, Buffer.from(buffer))
557
+ } catch (error) {
558
+ if (error instanceof Error && error.name === 'AbortError') {
559
+ throw new Error('Download timed out after 5 minutes')
560
+ }
561
+ throw error
562
+ } finally {
563
+ clearTimeout(timeout)
564
+ }
550
565
  }
551
566
 
552
567
  /**
@@ -4,57 +4,38 @@
4
4
  * Unlike PostgreSQL/MySQL which store containers in ~/.spindb/containers/,
5
5
  * SQLite databases are stored in user project directories. This registry
6
6
  * tracks the file paths of all SQLite databases managed by SpinDB.
7
+ *
8
+ * The registry is now stored in ~/.spindb/config.json under registry.sqlite
7
9
  */
8
10
 
9
11
  import { existsSync } from 'fs'
10
- import { readFile, writeFile, mkdir } from 'fs/promises'
11
- import { dirname } from 'path'
12
- import { paths } from '../../config/paths'
13
- import type { SQLiteRegistry, SQLiteRegistryEntry } from '../../types'
12
+ import { configManager } from '../../core/config-manager'
13
+ import type { SQLiteEngineRegistry, SQLiteRegistryEntry } from '../../types'
14
14
 
15
15
  /**
16
16
  * SQLite Registry Manager
17
- * Manages the JSON registry that tracks external SQLite database files
17
+ * Manages the registry that tracks external SQLite database files
18
+ * Data is stored in config.json under registry.sqlite
18
19
  */
19
20
  class SQLiteRegistryManager {
20
- private registryPath: string
21
-
22
- constructor() {
23
- this.registryPath = paths.getSqliteRegistryPath()
24
- }
25
-
26
21
  /**
27
- * Load the registry from disk
28
- * Returns an empty registry if the file doesn't exist
22
+ * Load the registry from config.json
23
+ * Returns an empty registry if none exists
29
24
  */
30
- async load(): Promise<SQLiteRegistry> {
31
- if (!existsSync(this.registryPath)) {
32
- return { version: 1, entries: [] }
33
- }
34
- try {
35
- const content = await readFile(this.registryPath, 'utf8')
36
- return JSON.parse(content) as SQLiteRegistry
37
- } catch {
38
- // If file is corrupted, return empty registry
39
- return { version: 1, entries: [] }
40
- }
25
+ async load(): Promise<SQLiteEngineRegistry> {
26
+ return configManager.getSqliteRegistry()
41
27
  }
42
28
 
43
29
  /**
44
- * Save the registry to disk
45
- * Creates the parent directory if it doesn't exist
30
+ * Save the registry to config.json
46
31
  */
47
- async save(registry: SQLiteRegistry): Promise<void> {
48
- const dir = dirname(this.registryPath)
49
- if (!existsSync(dir)) {
50
- await mkdir(dir, { recursive: true })
51
- }
52
- await writeFile(this.registryPath, JSON.stringify(registry, null, 2))
32
+ async save(registry: SQLiteEngineRegistry): Promise<void> {
33
+ await configManager.saveSqliteRegistry(registry)
53
34
  }
54
35
 
55
36
  /**
56
37
  * Add a new entry to the registry
57
- * @throws Error if a container with the same name already exists
38
+ * @throws Error if a container with the same name or file path already exists
58
39
  */
59
40
  async add(entry: SQLiteRegistryEntry): Promise<void> {
60
41
  const registry = await this.load()
@@ -64,6 +45,13 @@ class SQLiteRegistryManager {
64
45
  throw new Error(`SQLite container "${entry.name}" already exists`)
65
46
  }
66
47
 
48
+ // Check for duplicate file path
49
+ if (registry.entries.some((e) => e.filePath === entry.filePath)) {
50
+ throw new Error(
51
+ `SQLite container for path "${entry.filePath}" already exists`,
52
+ )
53
+ }
54
+
67
55
  registry.entries.push(entry)
68
56
  await this.save(registry)
69
57
  }
@@ -179,6 +167,49 @@ class SQLiteRegistryManager {
179
167
  const registry = await this.load()
180
168
  return registry.entries.find((e) => e.filePath === filePath) || null
181
169
  }
170
+
171
+ // ============================================================
172
+ // Folder Ignore Methods
173
+ // ============================================================
174
+
175
+ /**
176
+ * Check if a folder is in the ignore list
177
+ */
178
+ async isFolderIgnored(folderPath: string): Promise<boolean> {
179
+ const registry = await this.load()
180
+ return folderPath in registry.ignoreFolders
181
+ }
182
+
183
+ /**
184
+ * Add a folder to the ignore list
185
+ */
186
+ async addIgnoreFolder(folderPath: string): Promise<void> {
187
+ const registry = await this.load()
188
+ registry.ignoreFolders[folderPath] = true
189
+ await this.save(registry)
190
+ }
191
+
192
+ /**
193
+ * Remove a folder from the ignore list
194
+ * Returns true if the folder was in the list and removed, false otherwise
195
+ */
196
+ async removeIgnoreFolder(folderPath: string): Promise<boolean> {
197
+ const registry = await this.load()
198
+ if (folderPath in registry.ignoreFolders) {
199
+ delete registry.ignoreFolders[folderPath]
200
+ await this.save(registry)
201
+ return true
202
+ }
203
+ return false
204
+ }
205
+
206
+ /**
207
+ * List all ignored folders
208
+ */
209
+ async listIgnoredFolders(): Promise<string[]> {
210
+ const registry = await this.load()
211
+ return Object.keys(registry.ignoreFolders)
212
+ }
182
213
  }
183
214
 
184
215
  // Export singleton instance
@@ -0,0 +1,99 @@
1
+ /**
2
+ * SQLite Scanner
3
+ *
4
+ * Scans directories for unregistered SQLite database files.
5
+ * Used to detect SQLite databases in the current working directory
6
+ * that are not yet registered with SpinDB.
7
+ */
8
+
9
+ import { readdir } from 'fs/promises'
10
+ import { existsSync } from 'fs'
11
+ import { resolve } from 'path'
12
+ import { sqliteRegistry } from './registry'
13
+
14
+ export type UnregisteredFile = {
15
+ fileName: string
16
+ absolutePath: string
17
+ }
18
+
19
+ /**
20
+ * Scan a directory for unregistered SQLite files
21
+ * Returns files with .sqlite, .sqlite3, or .db extensions
22
+ * that are not already in the registry
23
+ *
24
+ * @param directory Directory to scan (defaults to CWD)
25
+ * @returns Array of unregistered SQLite files
26
+ */
27
+ export async function scanForUnregisteredSqliteFiles(
28
+ directory: string = process.cwd(),
29
+ ): Promise<UnregisteredFile[]> {
30
+ const absoluteDir = resolve(directory)
31
+
32
+ // Check if folder is ignored
33
+ if (await sqliteRegistry.isFolderIgnored(absoluteDir)) {
34
+ return []
35
+ }
36
+
37
+ // Check if directory exists
38
+ if (!existsSync(absoluteDir)) {
39
+ return []
40
+ }
41
+
42
+ try {
43
+ // Get all files in directory
44
+ const entries = await readdir(absoluteDir, { withFileTypes: true })
45
+
46
+ // Filter for SQLite files
47
+ const sqliteFiles = entries
48
+ .filter((e) => e.isFile())
49
+ .filter((e) => /\.(sqlite3?|db)$/i.test(e.name))
50
+ .map((e) => ({
51
+ fileName: e.name,
52
+ absolutePath: resolve(absoluteDir, e.name),
53
+ }))
54
+
55
+ // Filter out already registered files
56
+ const unregistered: UnregisteredFile[] = []
57
+ for (const file of sqliteFiles) {
58
+ if (!(await sqliteRegistry.isPathRegistered(file.absolutePath))) {
59
+ unregistered.push(file)
60
+ }
61
+ }
62
+
63
+ return unregistered
64
+ } catch {
65
+ // If we can't read the directory, return empty
66
+ return []
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Derive a valid container name from a filename
72
+ * Removes extension and converts to valid container name format:
73
+ * - Must start with a letter
74
+ * - Can contain letters, numbers, hyphens, underscores
75
+ *
76
+ * @param fileName The SQLite filename (e.g., "my-database.sqlite")
77
+ * @returns A valid container name (e.g., "my-database")
78
+ */
79
+ export function deriveContainerName(fileName: string): string {
80
+ // Remove extension
81
+ const base = fileName.replace(/\.(sqlite3?|db)$/i, '')
82
+
83
+ // Convert to valid container name (alphanumeric, hyphens, underscores)
84
+ // Replace invalid chars with hyphens
85
+ let name = base.replace(/[^a-zA-Z0-9_-]/g, '-')
86
+
87
+ // Ensure starts with letter
88
+ if (!/^[a-zA-Z]/.test(name)) {
89
+ name = 'db-' + name
90
+ }
91
+
92
+ // Remove consecutive hyphens
93
+ name = name.replace(/-+/g, '-')
94
+
95
+ // Trim leading/trailing hyphens
96
+ name = name.replace(/^-+|-+$/g, '')
97
+
98
+ return name || 'sqlite-db'
99
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL and MySQL.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,11 +9,12 @@
9
9
  "scripts": {
10
10
  "start": "tsx cli/bin.ts",
11
11
  "dev": "tsx watch cli/bin.ts",
12
- "test": "pnpm test:pg && pnpm test:mysql",
12
+ "test": "pnpm test:pg && pnpm test:mysql && pnpm test:sqlite",
13
13
  "test:pg": "node --import tsx --test tests/integration/postgresql.test.ts",
14
14
  "test:mysql": "node --import tsx --test tests/integration/mysql.test.ts",
15
+ "test:sqlite": "node --import tsx --test tests/integration/sqlite.test.ts",
15
16
  "test:unit": "node --import tsx --test tests/unit/*.test.ts",
16
- "test:integration": "pnpm test:pg && pnpm test:mysql",
17
+ "test:integration": "pnpm test:pg && pnpm test:mysql && pnpm test:sqlite",
17
18
  "test:all": "pnpm test:unit && pnpm test:integration",
18
19
  "format": "prettier --write .",
19
20
  "lint": "tsc --noEmit && eslint .",
package/types/index.ts CHANGED
@@ -149,6 +149,8 @@ export type SpinDBConfig = {
149
149
  litecli?: BinaryConfig
150
150
  usql?: BinaryConfig
151
151
  }
152
+ // Engine registries (for file-based databases like SQLite)
153
+ registry?: EngineRegistries
152
154
  // Default settings
153
155
  defaults?: {
154
156
  engine?: Engine
@@ -177,7 +179,25 @@ export type SQLiteRegistryEntry = {
177
179
  }
178
180
 
179
181
  /**
180
- * SQLite registry stored at ~/.spindb/sqlite-registry.json
182
+ * SQLite engine registry stored in config.json under registry.sqlite
183
+ * Includes entries and folder ignore list for CWD scanning
184
+ */
185
+ export type SQLiteEngineRegistry = {
186
+ version: 1
187
+ entries: SQLiteRegistryEntry[]
188
+ ignoreFolders: Record<string, true> // O(1) lookup for ignored folders
189
+ }
190
+
191
+ /**
192
+ * Engine registries stored in config.json
193
+ * Currently only SQLite uses this (file-based databases)
194
+ */
195
+ export type EngineRegistries = {
196
+ sqlite?: SQLiteEngineRegistry
197
+ }
198
+
199
+ /**
200
+ * @deprecated Use SQLiteEngineRegistry instead - now stored in config.json
181
201
  */
182
202
  export type SQLiteRegistry = {
183
203
  version: 1