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
@@ -1,9 +1,17 @@
1
+ import { existsSync } from 'fs'
1
2
  import chalk from 'chalk'
2
3
  import inquirer from 'inquirer'
3
4
  import { updateManager } from '../../../core/update-manager'
5
+ import { containerManager } from '../../../core/container-manager'
6
+ import { configManager } from '../../../core/config-manager'
7
+ import { sqliteRegistry } from '../../../engines/sqlite/registry'
8
+ import { paths } from '../../../config/paths'
9
+ import { getSupportedEngines } from '../../../config/engine-defaults'
10
+ import { checkEngineDependencies } from '../../../core/dependency-manager'
4
11
  import { createSpinner } from '../../ui/spinner'
5
12
  import { header, success, error, warning, info } from '../../ui/theme'
6
13
  import { pressEnterToContinue } from './shared'
14
+ import { Engine } from '../../../types'
7
15
 
8
16
  export async function handleCheckUpdate(): Promise<void> {
9
17
  console.clear()
@@ -92,3 +100,273 @@ export async function handleCheckUpdate(): Promise<void> {
92
100
  await pressEnterToContinue()
93
101
  }
94
102
  }
103
+
104
+ type HealthCheckResult = {
105
+ name: string
106
+ status: 'ok' | 'warning' | 'error'
107
+ message: string
108
+ details?: string[]
109
+ action?: {
110
+ label: string
111
+ handler: () => Promise<void>
112
+ }
113
+ }
114
+
115
+ async function checkConfiguration(): Promise<HealthCheckResult> {
116
+ const configPath = paths.config
117
+
118
+ if (!existsSync(configPath)) {
119
+ return {
120
+ name: 'Configuration',
121
+ status: 'ok',
122
+ message: 'No config file yet (will be created on first use)',
123
+ }
124
+ }
125
+
126
+ try {
127
+ const config = await configManager.load()
128
+ const binaryCount = Object.keys(config.binaries || {}).length
129
+ const isStale = await configManager.isStale()
130
+
131
+ if (isStale) {
132
+ return {
133
+ name: 'Configuration',
134
+ status: 'warning',
135
+ message: 'Binary cache is stale (>7 days old)',
136
+ details: [`Binary tools cached: ${binaryCount}`],
137
+ action: {
138
+ label: 'Refresh binary cache',
139
+ handler: async () => {
140
+ await configManager.refreshAllBinaries()
141
+ console.log(success('Binary cache refreshed'))
142
+ },
143
+ },
144
+ }
145
+ }
146
+
147
+ return {
148
+ name: 'Configuration',
149
+ status: 'ok',
150
+ message: 'Configuration valid',
151
+ details: [`Binary tools cached: ${binaryCount}`],
152
+ }
153
+ } catch (err) {
154
+ return {
155
+ name: 'Configuration',
156
+ status: 'error',
157
+ message: 'Configuration file is corrupted',
158
+ details: [(err as Error).message],
159
+ }
160
+ }
161
+ }
162
+
163
+ async function checkContainers(): Promise<HealthCheckResult> {
164
+ try {
165
+ const containers = await containerManager.list()
166
+
167
+ if (containers.length === 0) {
168
+ return {
169
+ name: 'Containers',
170
+ status: 'ok',
171
+ message: 'No containers (create one with: spindb create)',
172
+ }
173
+ }
174
+
175
+ const byEngine: Record<string, { running: number; stopped: number }> = {}
176
+
177
+ for (const c of containers) {
178
+ const engineName = c.engine
179
+ if (!byEngine[engineName]) {
180
+ byEngine[engineName] = { running: 0, stopped: 0 }
181
+ }
182
+ if (c.status === 'running') {
183
+ byEngine[engineName].running++
184
+ } else {
185
+ byEngine[engineName].stopped++
186
+ }
187
+ }
188
+
189
+ const details = Object.entries(byEngine).map(([engine, counts]) => {
190
+ if (engine === Engine.SQLite) {
191
+ return `${engine}: ${counts.running} exist, ${counts.stopped} missing`
192
+ }
193
+ return `${engine}: ${counts.running} running, ${counts.stopped} stopped`
194
+ })
195
+
196
+ return {
197
+ name: 'Containers',
198
+ status: 'ok',
199
+ message: `${containers.length} container(s)`,
200
+ details,
201
+ }
202
+ } catch (err) {
203
+ return {
204
+ name: 'Containers',
205
+ status: 'error',
206
+ message: 'Failed to list containers',
207
+ details: [(err as Error).message],
208
+ }
209
+ }
210
+ }
211
+
212
+ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
213
+ try {
214
+ const entries = await sqliteRegistry.list()
215
+
216
+ if (entries.length === 0) {
217
+ return {
218
+ name: 'SQLite Registry',
219
+ status: 'ok',
220
+ message: 'No SQLite databases registered',
221
+ }
222
+ }
223
+
224
+ const orphans = await sqliteRegistry.findOrphans()
225
+
226
+ if (orphans.length > 0) {
227
+ return {
228
+ name: 'SQLite Registry',
229
+ status: 'warning',
230
+ message: `${orphans.length} orphaned entr${orphans.length === 1 ? 'y' : 'ies'} found`,
231
+ details: orphans.map((o) => `"${o.name}" → ${o.filePath}`),
232
+ action: {
233
+ label: 'Remove orphaned entries from registry',
234
+ handler: async () => {
235
+ const count = await sqliteRegistry.removeOrphans()
236
+ console.log(success(`Removed ${count} orphaned entries`))
237
+ },
238
+ },
239
+ }
240
+ }
241
+
242
+ return {
243
+ name: 'SQLite Registry',
244
+ status: 'ok',
245
+ message: `${entries.length} database(s) registered, all files exist`,
246
+ }
247
+ } catch (err) {
248
+ return {
249
+ name: 'SQLite Registry',
250
+ status: 'warning',
251
+ message: 'Could not check registry',
252
+ details: [(err as Error).message],
253
+ }
254
+ }
255
+ }
256
+
257
+ async function checkBinaries(): Promise<HealthCheckResult> {
258
+ try {
259
+ const engines = getSupportedEngines()
260
+ const results: string[] = []
261
+ let hasWarning = false
262
+
263
+ for (const engine of engines) {
264
+ const statuses = await checkEngineDependencies(engine)
265
+ const installed = statuses.filter((s) => s.installed).length
266
+ const total = statuses.length
267
+
268
+ if (installed < total) {
269
+ hasWarning = true
270
+ results.push(`${engine}: ${installed}/${total} tools installed`)
271
+ } else {
272
+ results.push(`${engine}: all ${total} tools available`)
273
+ }
274
+ }
275
+
276
+ return {
277
+ name: 'Database Tools',
278
+ status: hasWarning ? 'warning' : 'ok',
279
+ message: hasWarning ? 'Some tools missing' : 'All tools available',
280
+ details: results,
281
+ }
282
+ } catch (err) {
283
+ return {
284
+ name: 'Database Tools',
285
+ status: 'error',
286
+ message: 'Failed to check tools',
287
+ details: [(err as Error).message],
288
+ }
289
+ }
290
+ }
291
+
292
+ function displayResult(result: HealthCheckResult): void {
293
+ const icon =
294
+ result.status === 'ok'
295
+ ? chalk.green('✓')
296
+ : result.status === 'warning'
297
+ ? chalk.yellow('⚠')
298
+ : chalk.red('✕')
299
+
300
+ console.log(`${icon} ${chalk.bold(result.name)}`)
301
+ console.log(` └─ ${result.message}`)
302
+
303
+ if (result.details) {
304
+ for (const detail of result.details) {
305
+ console.log(chalk.gray(` ${detail}`))
306
+ }
307
+ }
308
+ console.log()
309
+ }
310
+
311
+ export async function handleDoctor(): Promise<void> {
312
+ console.clear()
313
+ console.log(header('SpinDB Health Check'))
314
+ console.log()
315
+
316
+ const checks = [
317
+ await checkConfiguration(),
318
+ await checkContainers(),
319
+ await checkSqliteRegistry(),
320
+ await checkBinaries(),
321
+ ]
322
+
323
+ // Display results
324
+ for (const check of checks) {
325
+ displayResult(check)
326
+ }
327
+
328
+ // Collect actions for warnings
329
+ const actionsAvailable = checks.filter((c) => c.action)
330
+
331
+ if (actionsAvailable.length > 0) {
332
+ type ActionChoice = {
333
+ name: string
334
+ value: string
335
+ }
336
+
337
+ const choices: ActionChoice[] = [
338
+ ...actionsAvailable.map((c) => ({
339
+ name: c.action!.label,
340
+ value: c.name,
341
+ })),
342
+ { name: chalk.gray('Skip (do nothing)'), value: 'skip' },
343
+ ]
344
+
345
+ const { selectedAction } = await inquirer.prompt<{
346
+ selectedAction: string
347
+ }>([
348
+ {
349
+ type: 'list',
350
+ name: 'selectedAction',
351
+ message: 'What would you like to do?',
352
+ choices,
353
+ },
354
+ ])
355
+
356
+ if (selectedAction !== 'skip') {
357
+ const check = checks.find((c) => c.name === selectedAction)
358
+ if (check?.action) {
359
+ console.log()
360
+ await check.action.handler()
361
+ }
362
+ }
363
+ } else {
364
+ const hasIssues = checks.some((c) => c.status !== 'ok')
365
+ if (!hasIssues) {
366
+ console.log(chalk.green('All systems healthy! ✓'))
367
+ }
368
+ }
369
+
370
+ console.log()
371
+ await pressEnterToContinue()
372
+ }
@@ -16,6 +16,8 @@ import { tmpdir } from 'os'
16
16
  import { join } from 'path'
17
17
  import { getMissingDependencies } from '../../core/dependency-manager'
18
18
  import { platformService } from '../../core/platform-service'
19
+ import { TransactionManager } from '../../core/transaction-manager'
20
+ import { logDebug } from '../../core/error-handler'
19
21
 
20
22
  export const restoreCommand = new Command('restore')
21
23
  .description('Restore a backup to a container')
@@ -245,41 +247,99 @@ export const restoreCommand = new Command('restore')
245
247
  const format = await engine.detectBackupFormat(backupPath)
246
248
  detectSpinner.succeed(`Detected: ${format.description}`)
247
249
 
250
+ // Use TransactionManager to ensure database is cleaned up on restore failure
251
+ const tx = new TransactionManager()
252
+ let databaseCreated = false
253
+
248
254
  const dbSpinner = createSpinner(
249
255
  `Creating database "${databaseName}"...`,
250
256
  )
251
257
  dbSpinner.start()
252
258
 
253
- await engine.createDatabase(config, databaseName)
254
- dbSpinner.succeed(`Database "${databaseName}" ready`)
259
+ try {
260
+ await engine.createDatabase(config, databaseName)
261
+ databaseCreated = true
262
+ dbSpinner.succeed(`Database "${databaseName}" ready`)
263
+
264
+ // Register rollback to drop database if restore fails
265
+ tx.addRollback({
266
+ description: `Drop database "${databaseName}"`,
267
+ execute: async () => {
268
+ try {
269
+ await engine.dropDatabase(config, databaseName)
270
+ logDebug(`Rolled back: dropped database "${databaseName}"`)
271
+ } catch (dropErr) {
272
+ logDebug(
273
+ `Failed to drop database during rollback: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`,
274
+ )
275
+ }
276
+ },
277
+ })
278
+
279
+ await containerManager.addDatabase(containerName, databaseName)
280
+
281
+ // Register rollback to remove database from container tracking
282
+ tx.addRollback({
283
+ description: `Remove "${databaseName}" from container tracking`,
284
+ execute: async () => {
285
+ try {
286
+ await containerManager.removeDatabase(
287
+ containerName,
288
+ databaseName,
289
+ )
290
+ logDebug(
291
+ `Rolled back: removed "${databaseName}" from container tracking`,
292
+ )
293
+ } catch (removeErr) {
294
+ logDebug(
295
+ `Failed to remove database from tracking during rollback: ${removeErr instanceof Error ? removeErr.message : String(removeErr)}`,
296
+ )
297
+ }
298
+ },
299
+ })
255
300
 
256
- await containerManager.addDatabase(containerName, databaseName)
301
+ const restoreSpinner = createSpinner('Restoring backup...')
302
+ restoreSpinner.start()
257
303
 
258
- const restoreSpinner = createSpinner('Restoring backup...')
259
- restoreSpinner.start()
304
+ const result = await engine.restore(config, backupPath, {
305
+ database: databaseName,
306
+ createDatabase: false,
307
+ })
260
308
 
261
- const result = await engine.restore(config, backupPath, {
262
- database: databaseName,
263
- createDatabase: false,
264
- })
309
+ // Check if restore completely failed (non-zero code with no data restored)
310
+ if (result.code !== 0 && result.stderr?.includes('FATAL')) {
311
+ restoreSpinner.fail('Restore failed')
312
+ throw new Error(result.stderr || 'Restore failed with fatal error')
313
+ }
265
314
 
266
- if (result.code === 0 || !result.stderr) {
267
- restoreSpinner.succeed('Backup restored successfully')
268
- } else {
269
- // pg_restore often returns warnings even on success
270
- restoreSpinner.warn('Restore completed with warnings')
271
- if (result.stderr) {
272
- console.log(chalk.yellow('\n Warnings:'))
273
- const lines = result.stderr.split('\n').slice(0, 5)
274
- lines.forEach((line) => {
275
- if (line.trim()) {
276
- console.log(chalk.gray(` ${line}`))
315
+ if (result.code === 0) {
316
+ restoreSpinner.succeed('Backup restored successfully')
317
+ } else {
318
+ // pg_restore often returns warnings even on success
319
+ restoreSpinner.warn('Restore completed with warnings')
320
+ if (result.stderr) {
321
+ console.log(chalk.yellow('\n Warnings:'))
322
+ const lines = result.stderr.split('\n').slice(0, 5)
323
+ lines.forEach((line) => {
324
+ if (line.trim()) {
325
+ console.log(chalk.gray(` ${line}`))
326
+ }
327
+ })
328
+ if (result.stderr.split('\n').length > 5) {
329
+ console.log(chalk.gray(' ...'))
277
330
  }
278
- })
279
- if (result.stderr.split('\n').length > 5) {
280
- console.log(chalk.gray(' ...'))
281
331
  }
282
332
  }
333
+
334
+ // Restore succeeded - commit transaction (clear rollback actions)
335
+ tx.commit()
336
+ } catch (restoreErr) {
337
+ // Restore failed - execute rollbacks to clean up created database
338
+ if (databaseCreated) {
339
+ console.log(chalk.yellow('\n Cleaning up after failed restore...'))
340
+ await tx.rollback()
341
+ }
342
+ throw restoreErr
283
343
  }
284
344
 
285
345
  const connectionString = engine.getConnectionString(
@@ -7,6 +7,7 @@ import { getEngine } from '../../engines'
7
7
  import { promptInstallDependencies } from '../ui/prompts'
8
8
  import { error, warning } from '../ui/theme'
9
9
  import { getMissingDependencies } from '../../core/dependency-manager'
10
+ import { Engine } from '../../types'
10
11
 
11
12
  export const runCommand = new Command('run')
12
13
  .description('Run SQL file or statement against a container')
@@ -31,16 +32,29 @@ export const runCommand = new Command('run')
31
32
 
32
33
  const { engine: engineName } = config
33
34
 
34
- const running = await processManager.isRunning(containerName, {
35
- engine: engineName,
36
- })
37
- if (!running) {
38
- console.error(
39
- error(
40
- `Container "${containerName}" is not running. Start it first with: spindb start ${containerName}`,
41
- ),
42
- )
43
- process.exit(1)
35
+ // SQLite: check file exists instead of running status
36
+ if (engineName === Engine.SQLite) {
37
+ if (!existsSync(config.database)) {
38
+ console.error(
39
+ error(
40
+ `SQLite database file not found: ${config.database}`,
41
+ ),
42
+ )
43
+ process.exit(1)
44
+ }
45
+ } else {
46
+ // Server databases need to be running
47
+ const running = await processManager.isRunning(containerName, {
48
+ engine: engineName,
49
+ })
50
+ if (!running) {
51
+ console.error(
52
+ error(
53
+ `Container "${containerName}" is not running. Start it first with: spindb start ${containerName}`,
54
+ ),
55
+ )
56
+ process.exit(1)
57
+ }
44
58
  }
45
59
 
46
60
  if (file && options.sql) {
@@ -123,7 +137,9 @@ export const runCommand = new Command('run')
123
137
  const missingTool = matchingPattern
124
138
  .replace(' not found', '')
125
139
  .replace(' client', '')
126
- const installed = await promptInstallDependencies(missingTool)
140
+ // Determine engine from the missing tool name
141
+ const toolEngine = missingTool === 'mysql' ? Engine.MySQL : Engine.PostgreSQL
142
+ const installed = await promptInstallDependencies(missingTool, toolEngine)
127
143
  if (installed) {
128
144
  console.log(
129
145
  chalk.yellow(' Please re-run your command to continue.'),
@@ -47,15 +47,23 @@ export const urlCommand = new Command('url')
47
47
  const connectionString = engine.getConnectionString(config, databaseName)
48
48
 
49
49
  if (options.json) {
50
- const jsonOutput = {
51
- connectionString,
52
- host: '127.0.0.1',
53
- port: config.port,
54
- database: databaseName,
55
- user: config.engine === 'postgresql' ? 'postgres' : 'root',
56
- engine: config.engine,
57
- container: config.name,
58
- }
50
+ const jsonOutput =
51
+ config.engine === 'sqlite'
52
+ ? {
53
+ connectionString,
54
+ path: databaseName,
55
+ engine: config.engine,
56
+ container: config.name,
57
+ }
58
+ : {
59
+ connectionString,
60
+ host: '127.0.0.1',
61
+ port: config.port,
62
+ database: databaseName,
63
+ user: config.engine === 'postgresql' ? 'postgres' : 'root',
64
+ engine: config.engine,
65
+ container: config.name,
66
+ }
59
67
  console.log(JSON.stringify(jsonOutput, null, 2))
60
68
  return
61
69
  }
package/cli/constants.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export const ENGINE_ICONS: Record<string, string> = {
2
2
  postgresql: '🐘',
3
3
  mysql: '🐬',
4
+ sqlite: '🪶',
4
5
  }
5
6
 
6
7
  export const DEFAULT_ENGINE_ICON = '▣'
package/cli/helpers.ts CHANGED
@@ -30,7 +30,17 @@ export type InstalledMysqlEngine = {
30
30
  isMariaDB: boolean
31
31
  }
32
32
 
33
- export type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
33
+ export type InstalledSqliteEngine = {
34
+ engine: 'sqlite'
35
+ version: string
36
+ path: string
37
+ source: 'system'
38
+ }
39
+
40
+ export type InstalledEngine =
41
+ | InstalledPostgresEngine
42
+ | InstalledMysqlEngine
43
+ | InstalledSqliteEngine
34
44
 
35
45
  async function getPostgresVersion(binPath: string): Promise<string | null> {
36
46
  const postgresPath = join(binPath, 'bin', 'postgres')
@@ -125,6 +135,31 @@ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
125
135
  }
126
136
  }
127
137
 
138
+ async function getInstalledSqliteEngine(): Promise<InstalledSqliteEngine | null> {
139
+ try {
140
+ // TODO: Use 'where sqlite3' on Windows when adding Windows support
141
+ const { stdout: whichOutput } = await execAsync('which sqlite3')
142
+ const sqlitePath = whichOutput.trim()
143
+ if (!sqlitePath) {
144
+ return null
145
+ }
146
+
147
+ const { stdout: versionOutput } = await execAsync(`"${sqlitePath}" --version`)
148
+ // sqlite3 --version outputs: "3.43.2 2023-10-10 12:14:04 ..."
149
+ const versionMatch = versionOutput.match(/^([\d.]+)/)
150
+ const version = versionMatch ? versionMatch[1] : 'unknown'
151
+
152
+ return {
153
+ engine: 'sqlite',
154
+ version,
155
+ path: sqlitePath,
156
+ source: 'system',
157
+ }
158
+ } catch {
159
+ return null
160
+ }
161
+ }
162
+
128
163
  export function compareVersions(a: string, b: string): number {
129
164
  const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
130
165
  const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
@@ -148,5 +183,10 @@ export async function getInstalledEngines(): Promise<InstalledEngine[]> {
148
183
  engines.push(mysqlEngine)
149
184
  }
150
185
 
186
+ const sqliteEngine = await getInstalledSqliteEngine()
187
+ if (sqliteEngine) {
188
+ engines.push(sqliteEngine)
189
+ }
190
+
151
191
  return engines
152
192
  }
package/cli/index.ts CHANGED
@@ -24,6 +24,7 @@ import { selfUpdateCommand } from './commands/self-update'
24
24
  import { versionCommand } from './commands/version'
25
25
  import { runCommand } from './commands/run'
26
26
  import { logsCommand } from './commands/logs'
27
+ import { doctorCommand } from './commands/doctor'
27
28
  import { updateManager } from '../core/update-manager'
28
29
 
29
30
  /**
@@ -123,6 +124,7 @@ export async function run(): Promise<void> {
123
124
  program.addCommand(versionCommand)
124
125
  program.addCommand(runCommand)
125
126
  program.addCommand(logsCommand)
127
+ program.addCommand(doctorCommand)
126
128
 
127
129
  // If no arguments provided, show interactive menu
128
130
  if (process.argv.length <= 2) {