spindb 0.8.2 → 0.9.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.
Files changed (40) hide show
  1. package/README.md +87 -7
  2. package/cli/commands/clone.ts +6 -0
  3. package/cli/commands/connect.ts +115 -14
  4. package/cli/commands/create.ts +170 -8
  5. package/cli/commands/doctor.ts +320 -0
  6. package/cli/commands/edit.ts +209 -9
  7. package/cli/commands/engines.ts +34 -3
  8. package/cli/commands/info.ts +81 -26
  9. package/cli/commands/list.ts +64 -9
  10. package/cli/commands/logs.ts +9 -3
  11. package/cli/commands/menu/backup-handlers.ts +52 -21
  12. package/cli/commands/menu/container-handlers.ts +433 -127
  13. package/cli/commands/menu/engine-handlers.ts +128 -4
  14. package/cli/commands/menu/index.ts +5 -1
  15. package/cli/commands/menu/shell-handlers.ts +105 -21
  16. package/cli/commands/menu/sql-handlers.ts +16 -4
  17. package/cli/commands/menu/update-handlers.ts +278 -0
  18. package/cli/commands/restore.ts +83 -23
  19. package/cli/commands/run.ts +27 -11
  20. package/cli/commands/url.ts +17 -9
  21. package/cli/constants.ts +1 -0
  22. package/cli/helpers.ts +41 -1
  23. package/cli/index.ts +2 -0
  24. package/cli/ui/prompts.ts +148 -7
  25. package/config/engine-defaults.ts +14 -0
  26. package/config/os-dependencies.ts +66 -0
  27. package/config/paths.ts +8 -0
  28. package/core/container-manager.ts +191 -32
  29. package/core/dependency-manager.ts +18 -0
  30. package/core/error-handler.ts +31 -0
  31. package/core/port-manager.ts +2 -0
  32. package/core/process-manager.ts +25 -3
  33. package/engines/index.ts +4 -0
  34. package/engines/mysql/backup.ts +53 -36
  35. package/engines/mysql/index.ts +48 -5
  36. package/engines/postgresql/index.ts +6 -0
  37. package/engines/sqlite/index.ts +606 -0
  38. package/engines/sqlite/registry.ts +185 -0
  39. package/package.json +1 -1
  40. package/types/index.ts +26 -0
@@ -11,13 +11,24 @@ import {
11
11
  getInstalledEngines,
12
12
  type InstalledPostgresEngine,
13
13
  type InstalledMysqlEngine,
14
+ type InstalledSqliteEngine,
14
15
  } from '../../helpers'
15
16
  import {
16
17
  getMysqlVersion,
17
18
  getMysqlInstallInfo,
18
19
  } from '../../../engines/mysql/binary-detection'
20
+
19
21
  import { type MenuChoice } from './shared'
20
22
 
23
+ /**
24
+ * Pad string to width, accounting for emoji taking 2 display columns
25
+ */
26
+ function padWithEmoji(str: string, width: number): string {
27
+ // Count emojis using Extended_Pictographic (excludes digits/symbols that \p{Emoji} matches)
28
+ const emojiCount = (str.match(/\p{Extended_Pictographic}/gu) || []).length
29
+ return str.padEnd(width + emojiCount)
30
+ }
31
+
21
32
  export async function handleEngines(): Promise<void> {
22
33
  console.clear()
23
34
  console.log(header('Installed Engines'))
@@ -46,6 +57,9 @@ export async function handleEngines(): Promise<void> {
46
57
  const mysqlEngine = engines.find(
47
58
  (e): e is InstalledMysqlEngine => e.engine === 'mysql',
48
59
  )
60
+ const sqliteEngine = engines.find(
61
+ (e): e is InstalledSqliteEngine => e.engine === 'sqlite',
62
+ )
49
63
 
50
64
  const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
51
65
 
@@ -62,10 +76,11 @@ export async function handleEngines(): Promise<void> {
62
76
  for (const engine of pgEngines) {
63
77
  const icon = getEngineIcon(engine.engine)
64
78
  const platformInfo = `${engine.platform}-${engine.arch}`
79
+ const engineDisplay = `${icon} ${engine.engine}`
65
80
 
66
81
  console.log(
67
82
  chalk.gray(' ') +
68
- chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
83
+ chalk.cyan(padWithEmoji(engineDisplay, 13)) +
69
84
  chalk.yellow(engine.version.padEnd(12)) +
70
85
  chalk.gray(platformInfo.padEnd(18)) +
71
86
  chalk.white(formatBytes(engine.sizeBytes)),
@@ -75,16 +90,30 @@ export async function handleEngines(): Promise<void> {
75
90
  if (mysqlEngine) {
76
91
  const icon = ENGINE_ICONS.mysql
77
92
  const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
93
+ const engineDisplay = `${icon} ${displayName}`
78
94
 
79
95
  console.log(
80
96
  chalk.gray(' ') +
81
- chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
97
+ chalk.cyan(padWithEmoji(engineDisplay, 13)) +
82
98
  chalk.yellow(mysqlEngine.version.padEnd(12)) +
83
99
  chalk.gray('system'.padEnd(18)) +
84
100
  chalk.gray('(system-installed)'),
85
101
  )
86
102
  }
87
103
 
104
+ if (sqliteEngine) {
105
+ const icon = ENGINE_ICONS.sqlite
106
+ const engineDisplay = `${icon} sqlite`
107
+
108
+ console.log(
109
+ chalk.gray(' ') +
110
+ chalk.cyan(padWithEmoji(engineDisplay, 13)) +
111
+ chalk.yellow(sqliteEngine.version.padEnd(12)) +
112
+ chalk.gray('system'.padEnd(18)) +
113
+ chalk.gray('(system-installed)'),
114
+ )
115
+ }
116
+
88
117
  console.log(chalk.gray(' ' + '─'.repeat(55)))
89
118
 
90
119
  console.log()
@@ -98,6 +127,9 @@ export async function handleEngines(): Promise<void> {
98
127
  if (mysqlEngine) {
99
128
  console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
100
129
  }
130
+ if (sqliteEngine) {
131
+ console.log(chalk.gray(` SQLite: system-installed at ${sqliteEngine.path}`))
132
+ }
101
133
  console.log()
102
134
 
103
135
  const choices: MenuChoice[] = []
@@ -117,6 +149,13 @@ export async function handleEngines(): Promise<void> {
117
149
  })
118
150
  }
119
151
 
152
+ if (sqliteEngine) {
153
+ choices.push({
154
+ name: `${chalk.blue('ℹ')} SQLite ${sqliteEngine.version} ${chalk.gray('(system-installed)')}`,
155
+ value: `sqlite-info:${sqliteEngine.path}`,
156
+ })
157
+ }
158
+
120
159
  choices.push(new inquirer.Separator())
121
160
  choices.push({ name: `${chalk.blue('←')} Back to main menu`, value: 'back' })
122
161
 
@@ -135,16 +174,29 @@ export async function handleEngines(): Promise<void> {
135
174
  }
136
175
 
137
176
  if (action.startsWith('delete:')) {
138
- const [, enginePath, engineName, engineVersion] = action.split(':')
177
+ // Parse from the end to preserve colons in path
178
+ // Format: delete:path:engineName:engineVersion
179
+ const withoutPrefix = action.slice('delete:'.length)
180
+ const lastColon = withoutPrefix.lastIndexOf(':')
181
+ const secondLastColon = withoutPrefix.lastIndexOf(':', lastColon - 1)
182
+ const enginePath = withoutPrefix.slice(0, secondLastColon)
183
+ const engineName = withoutPrefix.slice(secondLastColon + 1, lastColon)
184
+ const engineVersion = withoutPrefix.slice(lastColon + 1)
139
185
  await handleDeleteEngine(enginePath, engineName, engineVersion)
140
186
  await handleEngines()
141
187
  }
142
188
 
143
189
  if (action.startsWith('mysql-info:')) {
144
- const mysqldPath = action.replace('mysql-info:', '')
190
+ const mysqldPath = action.slice('mysql-info:'.length)
145
191
  await handleMysqlInfo(mysqldPath)
146
192
  await handleEngines()
147
193
  }
194
+
195
+ if (action.startsWith('sqlite-info:')) {
196
+ const sqlitePath = action.slice('sqlite-info:'.length)
197
+ await handleSqliteInfo(sqlitePath)
198
+ await handleEngines()
199
+ }
148
200
  }
149
201
 
150
202
  async function handleDeleteEngine(
@@ -360,3 +412,75 @@ async function handleMysqlInfo(mysqldPath: string): Promise<void> {
360
412
  },
361
413
  ])
362
414
  }
415
+
416
+ async function handleSqliteInfo(sqlitePath: string): Promise<void> {
417
+ console.clear()
418
+
419
+ console.log(header('SQLite Information'))
420
+ console.log()
421
+
422
+ // Get version
423
+ let version = 'unknown'
424
+ try {
425
+ const { exec } = await import('child_process')
426
+ const { promisify } = await import('util')
427
+ const execAsync = promisify(exec)
428
+ const { stdout } = await execAsync(`"${sqlitePath}" --version`)
429
+ const match = stdout.match(/^([\d.]+)/)
430
+ if (match) {
431
+ version = match[1]
432
+ }
433
+ } catch {
434
+ // Ignore
435
+ }
436
+
437
+ const containers = await containerManager.list()
438
+ const sqliteContainers = containers.filter((c) => c.engine === 'sqlite')
439
+
440
+ if (sqliteContainers.length > 0) {
441
+ console.log(info(`${sqliteContainers.length} SQLite database(s) registered:`))
442
+ console.log()
443
+ for (const c of sqliteContainers) {
444
+ const status =
445
+ c.status === 'running'
446
+ ? chalk.blue('🔵 available')
447
+ : chalk.gray('⚪ missing')
448
+ console.log(chalk.gray(` • ${c.name} ${status}`))
449
+ }
450
+ console.log()
451
+ }
452
+
453
+ console.log(chalk.white(' Installation Details:'))
454
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
455
+ console.log(
456
+ chalk.gray(' ') +
457
+ chalk.white('Version:'.padEnd(18)) +
458
+ chalk.yellow(version),
459
+ )
460
+ console.log(
461
+ chalk.gray(' ') +
462
+ chalk.white('Binary Path:'.padEnd(18)) +
463
+ chalk.gray(sqlitePath),
464
+ )
465
+ console.log(
466
+ chalk.gray(' ') +
467
+ chalk.white('Type:'.padEnd(18)) +
468
+ chalk.cyan('Embedded (file-based)'),
469
+ )
470
+ console.log()
471
+
472
+ console.log(chalk.white(' Notes:'))
473
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
474
+ console.log(chalk.gray(' • SQLite is typically pre-installed on macOS and most Linux distributions'))
475
+ console.log(chalk.gray(' • No server process - databases are just files'))
476
+ console.log(chalk.gray(' • Use "spindb delete <name>" to unregister a database'))
477
+ console.log()
478
+
479
+ await inquirer.prompt([
480
+ {
481
+ type: 'input',
482
+ name: 'continue',
483
+ message: chalk.gray('Press Enter to go back...'),
484
+ },
485
+ ])
486
+ }
@@ -13,7 +13,7 @@ import {
13
13
  } from './container-handlers'
14
14
  import { handleBackup, handleRestore, handleClone } from './backup-handlers'
15
15
  import { handleEngines } from './engine-handlers'
16
- import { handleCheckUpdate } from './update-handlers'
16
+ import { handleCheckUpdate, handleDoctor } from './update-handlers'
17
17
  import { type MenuChoice } from './shared'
18
18
 
19
19
  async function showMainMenu(): Promise<void> {
@@ -97,6 +97,7 @@ async function showMainMenu(): Promise<void> {
97
97
  disabled: hasEngines ? false : 'No engines installed',
98
98
  },
99
99
  new inquirer.Separator(),
100
+ { name: `${chalk.bgRed.white('+')} System health check`, value: 'doctor' },
100
101
  { name: `${chalk.cyan('↑')} Check for updates`, value: 'check-update' },
101
102
  { name: `${chalk.gray('⏻')} Exit`, value: 'exit' },
102
103
  ]
@@ -136,6 +137,9 @@ async function showMainMenu(): Promise<void> {
136
137
  case 'engines':
137
138
  await handleEngines()
138
139
  break
140
+ case 'doctor':
141
+ await handleDoctor()
142
+ break
139
143
  case 'check-update':
140
144
  await handleCheckUpdate()
141
145
  break
@@ -6,13 +6,16 @@ import {
6
6
  isUsqlInstalled,
7
7
  isPgcliInstalled,
8
8
  isMycliInstalled,
9
+ isLitecliInstalled,
9
10
  detectPackageManager,
10
11
  installUsql,
11
12
  installPgcli,
12
13
  installMycli,
14
+ installLitecli,
13
15
  getUsqlManualInstructions,
14
16
  getPgcliManualInstructions,
15
17
  getMycliManualInstructions,
18
+ getLitecliManualInstructions,
16
19
  } from '../../../core/dependency-manager'
17
20
  import { platformService } from '../../../core/platform-service'
18
21
  import { getEngine } from '../../../engines'
@@ -66,10 +69,11 @@ export async function handleOpenShell(containerName: string): Promise<void> {
66
69
  const shellCheckSpinner = createSpinner('Checking available shells...')
67
70
  shellCheckSpinner.start()
68
71
 
69
- const [usqlInstalled, pgcliInstalled, mycliInstalled] = await Promise.all([
72
+ const [usqlInstalled, pgcliInstalled, mycliInstalled, litecliInstalled] = await Promise.all([
70
73
  isUsqlInstalled(),
71
74
  isPgcliInstalled(),
72
75
  isMycliInstalled(),
76
+ isLitecliInstalled(),
73
77
  ])
74
78
 
75
79
  shellCheckSpinner.stop()
@@ -84,12 +88,36 @@ export async function handleOpenShell(containerName: string): Promise<void> {
84
88
  | 'install-pgcli'
85
89
  | 'mycli'
86
90
  | 'install-mycli'
91
+ | 'litecli'
92
+ | 'install-litecli'
87
93
  | 'back'
88
94
 
89
- const defaultShellName = config.engine === 'mysql' ? 'mysql' : 'psql'
90
- const engineSpecificCli = config.engine === 'mysql' ? 'mycli' : 'pgcli'
91
- const engineSpecificInstalled =
92
- config.engine === 'mysql' ? mycliInstalled : pgcliInstalled
95
+ // Engine-specific shell names
96
+ let defaultShellName: string
97
+ let engineSpecificCli: string
98
+ let engineSpecificInstalled: boolean
99
+ let engineSpecificValue: ShellChoice
100
+ let engineSpecificInstallValue: ShellChoice
101
+
102
+ if (config.engine === 'sqlite') {
103
+ defaultShellName = 'sqlite3'
104
+ engineSpecificCli = 'litecli'
105
+ engineSpecificInstalled = litecliInstalled
106
+ engineSpecificValue = 'litecli'
107
+ engineSpecificInstallValue = 'install-litecli'
108
+ } else if (config.engine === 'mysql') {
109
+ defaultShellName = 'mysql'
110
+ engineSpecificCli = 'mycli'
111
+ engineSpecificInstalled = mycliInstalled
112
+ engineSpecificValue = 'mycli'
113
+ engineSpecificInstallValue = 'install-mycli'
114
+ } else {
115
+ defaultShellName = 'psql'
116
+ engineSpecificCli = 'pgcli'
117
+ engineSpecificInstalled = pgcliInstalled
118
+ engineSpecificValue = 'pgcli'
119
+ engineSpecificInstallValue = 'install-pgcli'
120
+ }
93
121
 
94
122
  const choices: Array<{ name: string; value: ShellChoice } | inquirer.Separator> = [
95
123
  {
@@ -101,15 +129,16 @@ export async function handleOpenShell(containerName: string): Promise<void> {
101
129
  if (engineSpecificInstalled) {
102
130
  choices.push({
103
131
  name: `⚡ Use ${engineSpecificCli} (enhanced features, recommended)`,
104
- value: config.engine === 'mysql' ? 'mycli' : 'pgcli',
132
+ value: engineSpecificValue,
105
133
  })
106
134
  } else {
107
135
  choices.push({
108
136
  name: `↓ Install ${engineSpecificCli} (enhanced features, recommended)`,
109
- value: config.engine === 'mysql' ? 'install-mycli' : 'install-pgcli',
137
+ value: engineSpecificInstallValue,
110
138
  })
111
139
  }
112
140
 
141
+ // usql supports SQLite too
113
142
  if (usqlInstalled) {
114
143
  choices.push({
115
144
  name: '⚡ Use usql (universal SQL client)',
@@ -241,6 +270,39 @@ export async function handleOpenShell(containerName: string): Promise<void> {
241
270
  return
242
271
  }
243
272
 
273
+ if (shellChoice === 'install-litecli') {
274
+ console.log()
275
+ console.log(info('Installing litecli for enhanced SQLite shell...'))
276
+ const pm = await detectPackageManager()
277
+ if (pm) {
278
+ const result = await installLitecli(pm)
279
+ if (result.success) {
280
+ console.log(success('litecli installed successfully!'))
281
+ console.log()
282
+ await launchShell(containerName, config, connectionString, 'litecli')
283
+ } else {
284
+ console.error(error(`Failed to install litecli: ${result.error}`))
285
+ console.log()
286
+ console.log(chalk.gray('Manual installation:'))
287
+ for (const instruction of getLitecliManualInstructions()) {
288
+ console.log(chalk.cyan(` ${instruction}`))
289
+ }
290
+ console.log()
291
+ await pressEnterToContinue()
292
+ }
293
+ } else {
294
+ console.error(error('No supported package manager found'))
295
+ console.log()
296
+ console.log(chalk.gray('Manual installation:'))
297
+ for (const instruction of getLitecliManualInstructions()) {
298
+ console.log(chalk.cyan(` ${instruction}`))
299
+ }
300
+ console.log()
301
+ await pressEnterToContinue()
302
+ }
303
+ return
304
+ }
305
+
244
306
  await launchShell(containerName, config, connectionString, shellChoice)
245
307
  }
246
308
 
@@ -248,7 +310,7 @@ async function launchShell(
248
310
  containerName: string,
249
311
  config: NonNullable<Awaited<ReturnType<typeof containerManager.getConfig>>>,
250
312
  connectionString: string,
251
- shellType: 'default' | 'usql' | 'pgcli' | 'mycli',
313
+ shellType: 'default' | 'usql' | 'pgcli' | 'mycli' | 'litecli',
252
314
  ): Promise<void> {
253
315
  console.log(info(`Connecting to ${containerName}...`))
254
316
  console.log()
@@ -275,11 +337,21 @@ async function launchShell(
275
337
  config.database,
276
338
  ]
277
339
  installHint = 'brew install mycli'
340
+ } else if (shellType === 'litecli') {
341
+ // litecli takes the database file path directly
342
+ shellCmd = 'litecli'
343
+ shellArgs = [config.database]
344
+ installHint = 'brew install litecli'
278
345
  } else if (shellType === 'usql') {
279
- // usql accepts connection strings directly for both PostgreSQL and MySQL
346
+ // usql accepts connection strings directly for PostgreSQL, MySQL, and SQLite
280
347
  shellCmd = 'usql'
281
348
  shellArgs = [connectionString]
282
349
  installHint = 'brew tap xo/xo && brew install xo/xo/usql'
350
+ } else if (config.engine === 'sqlite') {
351
+ // Default SQLite shell
352
+ shellCmd = 'sqlite3'
353
+ shellArgs = [config.database]
354
+ installHint = 'brew install sqlite3'
283
355
  } else if (config.engine === 'mysql') {
284
356
  shellCmd = 'mysql'
285
357
  shellArgs = [
@@ -302,19 +374,31 @@ async function launchShell(
302
374
  stdio: 'inherit',
303
375
  })
304
376
 
305
- shellProcess.on('error', (err: NodeJS.ErrnoException) => {
306
- if (err.code === 'ENOENT') {
307
- console.log(warning(`${shellCmd} not found on your system.`))
308
- console.log()
309
- console.log(chalk.gray(' Connect manually with:'))
310
- console.log(chalk.cyan(` ${connectionString}`))
311
- console.log()
312
- console.log(chalk.gray(` Install ${shellCmd}:`))
313
- console.log(chalk.cyan(` ${installHint}`))
377
+ await new Promise<void>((resolve) => {
378
+ let settled = false
379
+
380
+ const settle = () => {
381
+ if (!settled) {
382
+ settled = true
383
+ resolve()
384
+ }
314
385
  }
315
- })
316
386
 
317
- await new Promise<void>((resolve) => {
318
- shellProcess.on('close', () => resolve())
387
+ shellProcess.on('error', (err: NodeJS.ErrnoException) => {
388
+ if (err.code === 'ENOENT') {
389
+ console.log(warning(`${shellCmd} not found on your system.`))
390
+ console.log()
391
+ console.log(chalk.gray(' Connect manually with:'))
392
+ console.log(chalk.cyan(` ${connectionString}`))
393
+ console.log()
394
+ console.log(chalk.gray(` Install ${shellCmd}:`))
395
+ console.log(chalk.cyan(` ${installHint}`))
396
+ } else {
397
+ console.log(error(`Failed to start ${shellCmd}: ${err.message}`))
398
+ }
399
+ settle()
400
+ })
401
+
402
+ shellProcess.on('close', settle)
319
403
  })
320
404
  }
@@ -169,11 +169,23 @@ export async function handleViewLogs(containerName: string): Promise<void> {
169
169
  stdio: 'inherit',
170
170
  })
171
171
  await new Promise<void>((resolve) => {
172
- process.on('SIGINT', () => {
172
+ let settled = false
173
+
174
+ const cleanup = () => {
175
+ if (!settled) {
176
+ settled = true
177
+ process.off('SIGINT', handleSigint)
178
+ resolve()
179
+ }
180
+ }
181
+
182
+ const handleSigint = () => {
173
183
  child.kill('SIGTERM')
174
- resolve()
175
- })
176
- child.on('close', () => resolve())
184
+ cleanup()
185
+ }
186
+
187
+ process.on('SIGINT', handleSigint)
188
+ child.on('close', cleanup)
177
189
  })
178
190
  return
179
191
  }