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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Doctor command - System health checks and diagnostics
3
+ *
4
+ * Checks:
5
+ * 1. Configuration file validity
6
+ * 2. Container status across all engines
7
+ * 3. SQLite registry orphaned entries
8
+ * 4. Binary/tool availability
9
+ */
10
+
11
+ import { Command } from 'commander'
12
+ import { existsSync } from 'fs'
13
+ import chalk from 'chalk'
14
+ import inquirer from 'inquirer'
15
+ import { containerManager } from '../../core/container-manager'
16
+ import { configManager } from '../../core/config-manager'
17
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
18
+ import { paths } from '../../config/paths'
19
+ import { getSupportedEngines } from '../../config/engine-defaults'
20
+ import { checkEngineDependencies } from '../../core/dependency-manager'
21
+ import { header, success } from '../ui/theme'
22
+ import { Engine } from '../../types'
23
+
24
+ type HealthCheckResult = {
25
+ name: string
26
+ status: 'ok' | 'warning' | 'error'
27
+ message: string
28
+ details?: string[]
29
+ action?: {
30
+ label: string
31
+ handler: () => Promise<void>
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Check configuration file validity
37
+ */
38
+ async function checkConfiguration(): Promise<HealthCheckResult> {
39
+ const configPath = paths.config
40
+
41
+ if (!existsSync(configPath)) {
42
+ return {
43
+ name: 'Configuration',
44
+ status: 'ok',
45
+ message: 'No config file yet (will be created on first use)',
46
+ }
47
+ }
48
+
49
+ try {
50
+ const config = await configManager.load()
51
+ const binaryCount = Object.keys(config.binaries || {}).length
52
+ const isStale = await configManager.isStale()
53
+
54
+ if (isStale) {
55
+ return {
56
+ name: 'Configuration',
57
+ status: 'warning',
58
+ message: 'Binary cache is stale (>7 days old)',
59
+ details: [`Binary tools cached: ${binaryCount}`],
60
+ action: {
61
+ label: 'Refresh binary cache',
62
+ handler: async () => {
63
+ await configManager.refreshAllBinaries()
64
+ console.log(success('Binary cache refreshed'))
65
+ },
66
+ },
67
+ }
68
+ }
69
+
70
+ return {
71
+ name: 'Configuration',
72
+ status: 'ok',
73
+ message: 'Configuration valid',
74
+ details: [`Binary tools cached: ${binaryCount}`],
75
+ }
76
+ } catch (err) {
77
+ return {
78
+ name: 'Configuration',
79
+ status: 'error',
80
+ message: 'Configuration file is corrupted',
81
+ details: [(err as Error).message],
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check container status across all engines
88
+ */
89
+ async function checkContainers(): Promise<HealthCheckResult> {
90
+ try {
91
+ const containers = await containerManager.list()
92
+
93
+ if (containers.length === 0) {
94
+ return {
95
+ name: 'Containers',
96
+ status: 'ok',
97
+ message: 'No containers (create one with: spindb create)',
98
+ }
99
+ }
100
+
101
+ const byEngine: Record<string, { running: number; stopped: number }> = {}
102
+
103
+ for (const c of containers) {
104
+ const engineName = c.engine
105
+ if (!byEngine[engineName]) {
106
+ byEngine[engineName] = { running: 0, stopped: 0 }
107
+ }
108
+ if (c.status === 'running') {
109
+ byEngine[engineName].running++
110
+ } else {
111
+ byEngine[engineName].stopped++
112
+ }
113
+ }
114
+
115
+ const details = Object.entries(byEngine).map(([engine, counts]) => {
116
+ if (engine === Engine.SQLite) {
117
+ return `${engine}: ${counts.running} exist, ${counts.stopped} missing`
118
+ }
119
+ return `${engine}: ${counts.running} running, ${counts.stopped} stopped`
120
+ })
121
+
122
+ return {
123
+ name: 'Containers',
124
+ status: 'ok',
125
+ message: `${containers.length} container(s)`,
126
+ details,
127
+ }
128
+ } catch (err) {
129
+ return {
130
+ name: 'Containers',
131
+ status: 'error',
132
+ message: 'Failed to list containers',
133
+ details: [(err as Error).message],
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Check SQLite registry for orphaned entries
140
+ */
141
+ async function checkSqliteRegistry(): Promise<HealthCheckResult> {
142
+ try {
143
+ const entries = await sqliteRegistry.list()
144
+
145
+ if (entries.length === 0) {
146
+ return {
147
+ name: 'SQLite Registry',
148
+ status: 'ok',
149
+ message: 'No SQLite databases registered',
150
+ }
151
+ }
152
+
153
+ const orphans = await sqliteRegistry.findOrphans()
154
+
155
+ if (orphans.length > 0) {
156
+ return {
157
+ name: 'SQLite Registry',
158
+ status: 'warning',
159
+ message: `${orphans.length} orphaned entr${orphans.length === 1 ? 'y' : 'ies'} found`,
160
+ details: orphans.map((o) => `"${o.name}" → ${o.filePath}`),
161
+ action: {
162
+ label: 'Remove orphaned entries from registry',
163
+ handler: async () => {
164
+ const count = await sqliteRegistry.removeOrphans()
165
+ console.log(success(`Removed ${count} orphaned entries`))
166
+ },
167
+ },
168
+ }
169
+ }
170
+
171
+ return {
172
+ name: 'SQLite Registry',
173
+ status: 'ok',
174
+ message: `${entries.length} database(s) registered, all files exist`,
175
+ }
176
+ } catch (err) {
177
+ return {
178
+ name: 'SQLite Registry',
179
+ status: 'warning',
180
+ message: 'Could not check registry',
181
+ details: [(err as Error).message],
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Check binary/tool availability for all engines
188
+ */
189
+ async function checkBinaries(): Promise<HealthCheckResult> {
190
+ try {
191
+ const engines = getSupportedEngines()
192
+ const results: string[] = []
193
+ let hasWarning = false
194
+
195
+ for (const engine of engines) {
196
+ const statuses = await checkEngineDependencies(engine)
197
+ const installed = statuses.filter((s) => s.installed).length
198
+ const total = statuses.length
199
+
200
+ if (installed < total) {
201
+ hasWarning = true
202
+ results.push(`${engine}: ${installed}/${total} tools installed`)
203
+ } else {
204
+ results.push(`${engine}: all ${total} tools available`)
205
+ }
206
+ }
207
+
208
+ return {
209
+ name: 'Database Tools',
210
+ status: hasWarning ? 'warning' : 'ok',
211
+ message: hasWarning ? 'Some tools missing' : 'All tools available',
212
+ details: results,
213
+ }
214
+ } catch (err) {
215
+ return {
216
+ name: 'Database Tools',
217
+ status: 'error',
218
+ message: 'Failed to check tools',
219
+ details: [(err as Error).message],
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Display a single health check result
226
+ */
227
+ function displayResult(result: HealthCheckResult): void {
228
+ const icon =
229
+ result.status === 'ok'
230
+ ? chalk.green('✓')
231
+ : result.status === 'warning'
232
+ ? chalk.yellow('⚠')
233
+ : chalk.red('✕')
234
+
235
+ console.log(`${icon} ${chalk.bold(result.name)}`)
236
+ console.log(` └─ ${result.message}`)
237
+
238
+ if (result.details) {
239
+ for (const detail of result.details) {
240
+ console.log(chalk.gray(` ${detail}`))
241
+ }
242
+ }
243
+ console.log()
244
+ }
245
+
246
+ export const doctorCommand = new Command('doctor')
247
+ .description('Check system health and fix common issues')
248
+ .option('--json', 'Output as JSON')
249
+ .action(async (options: { json?: boolean }) => {
250
+ console.log()
251
+ console.log(header('SpinDB Health Check'))
252
+ console.log()
253
+
254
+ const checks = [
255
+ await checkConfiguration(),
256
+ await checkContainers(),
257
+ await checkSqliteRegistry(),
258
+ await checkBinaries(),
259
+ ]
260
+
261
+ if (options.json) {
262
+ // Strip action handlers for JSON output
263
+ const jsonChecks = checks.map(({ action: _action, ...rest }) => rest)
264
+ console.log(JSON.stringify(jsonChecks, null, 2))
265
+ return
266
+ }
267
+
268
+ // Display results
269
+ for (const check of checks) {
270
+ displayResult(check)
271
+ }
272
+
273
+ // Collect actions for warnings
274
+ const actionsAvailable = checks.filter((c) => c.action)
275
+
276
+ if (actionsAvailable.length > 0) {
277
+ type ActionChoice = {
278
+ name: string
279
+ value: string
280
+ }
281
+
282
+ const choices: ActionChoice[] = [
283
+ ...actionsAvailable.map((c) => ({
284
+ name: c.action!.label,
285
+ value: c.name,
286
+ })),
287
+ { name: chalk.gray('Skip (do nothing)'), value: 'skip' },
288
+ ]
289
+
290
+ const { selectedAction } = await inquirer.prompt<{
291
+ selectedAction: string
292
+ }>([
293
+ {
294
+ type: 'list',
295
+ name: 'selectedAction',
296
+ message: 'What would you like to do?',
297
+ choices,
298
+ },
299
+ ])
300
+
301
+ if (selectedAction === 'skip') {
302
+ return
303
+ }
304
+
305
+ // Execute the selected action
306
+ const check = checks.find((c) => c.name === selectedAction)
307
+ if (check?.action) {
308
+ console.log()
309
+ await check.action.handler()
310
+ }
311
+ } else {
312
+ const hasIssues = checks.some((c) => c.status !== 'ok')
313
+ if (!hasIssues) {
314
+ console.log(chalk.green('All systems healthy! ✓'))
315
+ }
316
+ }
317
+
318
+ console.log()
319
+ })
@@ -1,14 +1,19 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import inquirer from 'inquirer'
4
+ import { existsSync, renameSync, mkdirSync, statSync, unlinkSync, copyFileSync } from 'fs'
5
+ import { dirname, resolve, basename, join } from 'path'
6
+ import { homedir } from 'os'
4
7
  import { containerManager } from '../../core/container-manager'
5
8
  import { processManager } from '../../core/process-manager'
6
9
  import { portManager } from '../../core/port-manager'
7
10
  import { getEngine } from '../../engines'
11
+ import { sqliteRegistry } from '../../engines/sqlite/registry'
8
12
  import { paths } from '../../config/paths'
9
13
  import { promptContainerSelect } from '../ui/prompts'
10
14
  import { createSpinner } from '../ui/spinner'
11
15
  import { error, warning, success, info } from '../ui/theme'
16
+ import { Engine } from '../../types'
12
17
 
13
18
  function isValidName(name: string): boolean {
14
19
  return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
@@ -19,12 +24,18 @@ function isValidName(name: string): boolean {
19
24
  */
20
25
  async function promptEditAction(
21
26
  engine: string,
22
- ): Promise<'name' | 'port' | 'config' | null> {
27
+ ): Promise<'name' | 'port' | 'config' | 'relocate' | null> {
23
28
  const choices = [
24
29
  { name: 'Rename container', value: 'name' },
25
- { name: 'Change port', value: 'port' },
26
30
  ]
27
31
 
32
+ // SQLite: show relocate instead of port
33
+ if (engine === Engine.SQLite) {
34
+ choices.push({ name: 'Relocate database file', value: 'relocate' })
35
+ } else {
36
+ choices.push({ name: 'Change port', value: 'port' })
37
+ }
38
+
28
39
  // Only show config option for engines that support it
29
40
  if (engine === 'postgresql') {
30
41
  choices.push({ name: 'Edit database config (postgresql.conf)', value: 'config' })
@@ -42,7 +53,7 @@ async function promptEditAction(
42
53
  ])
43
54
 
44
55
  if (action === 'cancel') return null
45
- return action as 'name' | 'port' | 'config'
56
+ return action as 'name' | 'port' | 'config' | 'relocate'
46
57
  }
47
58
 
48
59
  async function promptNewName(currentName: string): Promise<string | null> {
@@ -173,11 +184,71 @@ async function promptNewPort(currentPort: number): Promise<number | null> {
173
184
  return newPort
174
185
  }
175
186
 
187
+ /**
188
+ * Prompt for new file location (SQLite relocate)
189
+ */
190
+ async function promptNewLocation(currentPath: string): Promise<string | null> {
191
+ console.log()
192
+ console.log(chalk.gray(` Current location: ${currentPath}`))
193
+ console.log(chalk.gray(' Enter an absolute path or relative to current directory.'))
194
+ console.log()
195
+
196
+ const { newPath } = await inquirer.prompt<{ newPath: string }>([
197
+ {
198
+ type: 'input',
199
+ name: 'newPath',
200
+ message: 'New file location:',
201
+ default: currentPath,
202
+ validate: (input: string) => {
203
+ if (!input.trim()) return 'Path is required'
204
+ const resolvedPath = resolve(input)
205
+ if (!resolvedPath.endsWith('.sqlite') && !resolvedPath.endsWith('.db') && !resolvedPath.endsWith('.sqlite3')) {
206
+ return 'Path should end with .sqlite, .sqlite3, or .db'
207
+ }
208
+ return true
209
+ },
210
+ },
211
+ ])
212
+
213
+ const resolvedPath = resolve(newPath)
214
+
215
+ if (resolvedPath === currentPath) {
216
+ console.log(warning('Location unchanged'))
217
+ return null
218
+ }
219
+
220
+ // Check if target already exists
221
+ if (existsSync(resolvedPath)) {
222
+ const { overwrite } = await inquirer.prompt<{ overwrite: boolean }>([
223
+ {
224
+ type: 'confirm',
225
+ name: 'overwrite',
226
+ message: `File already exists at ${resolvedPath}. Overwrite?`,
227
+ default: false,
228
+ },
229
+ ])
230
+ if (!overwrite) {
231
+ console.log(warning('Relocate cancelled'))
232
+ return null
233
+ }
234
+ }
235
+
236
+ return resolvedPath
237
+ }
238
+
176
239
  export const editCommand = new Command('edit')
177
- .description('Edit container properties (rename, port, or database config)')
240
+ .description('Edit container properties (rename, port, relocate, or database config)')
178
241
  .argument('[name]', 'Container name')
179
242
  .option('-n, --name <newName>', 'New container name')
180
243
  .option('-p, --port <port>', 'New port number', parseInt)
244
+ .option(
245
+ '--relocate <path>',
246
+ 'New file location for SQLite database (moves the file)',
247
+ )
248
+ .option(
249
+ '--overwrite',
250
+ 'Overwrite destination file if it exists (for --relocate)',
251
+ )
181
252
  .option(
182
253
  '--set-config <setting>',
183
254
  'Set a database config value (e.g., max_connections=200)',
@@ -185,7 +256,7 @@ export const editCommand = new Command('edit')
185
256
  .action(
186
257
  async (
187
258
  name: string | undefined,
188
- options: { name?: string; port?: number; setConfig?: string },
259
+ options: { name?: string; port?: number; relocate?: string; overwrite?: boolean; setConfig?: string },
189
260
  ) => {
190
261
  try {
191
262
  let containerName = name
@@ -216,6 +287,7 @@ export const editCommand = new Command('edit')
216
287
  if (
217
288
  options.name === undefined &&
218
289
  options.port === undefined &&
290
+ options.relocate === undefined &&
219
291
  options.setConfig === undefined
220
292
  ) {
221
293
  const action = await promptEditAction(config.engine)
@@ -235,6 +307,13 @@ export const editCommand = new Command('edit')
235
307
  } else {
236
308
  return
237
309
  }
310
+ } else if (action === 'relocate') {
311
+ const newLocation = await promptNewLocation(config.database)
312
+ if (newLocation) {
313
+ options.relocate = newLocation
314
+ } else {
315
+ return
316
+ }
238
317
  } else if (action === 'config') {
239
318
  const configSetting = await promptConfigSetting()
240
319
  if (configSetting) {
@@ -317,6 +396,125 @@ export const editCommand = new Command('edit')
317
396
  )
318
397
  }
319
398
 
399
+ // Handle SQLite relocate
400
+ if (options.relocate) {
401
+ if (config.engine !== Engine.SQLite) {
402
+ console.error(
403
+ error('Relocate is only available for SQLite containers'),
404
+ )
405
+ process.exit(1)
406
+ }
407
+
408
+ // Expand ~ to home directory
409
+ let expandedPath = options.relocate
410
+ if (options.relocate === '~') {
411
+ expandedPath = homedir()
412
+ } else if (options.relocate.startsWith('~/')) {
413
+ expandedPath = join(homedir(), options.relocate.slice(2))
414
+ }
415
+
416
+ // Convert relative paths to absolute
417
+ if (!expandedPath.startsWith('/')) {
418
+ expandedPath = resolve(process.cwd(), expandedPath)
419
+ }
420
+
421
+ // Check if path looks like a file (has db extension) or directory
422
+ const hasDbExtension = /\.(sqlite3?|db)$/i.test(expandedPath)
423
+
424
+ // Treat as directory if:
425
+ // - ends with /
426
+ // - exists and is a directory
427
+ // - doesn't have a database file extension
428
+ const isDirectory = expandedPath.endsWith('/') ||
429
+ (existsSync(expandedPath) && statSync(expandedPath).isDirectory()) ||
430
+ !hasDbExtension
431
+
432
+ let newPath: string
433
+ if (isDirectory) {
434
+ const dirPath = expandedPath.endsWith('/') ? expandedPath.slice(0, -1) : expandedPath
435
+ const currentFileName = basename(config.database)
436
+ newPath = join(dirPath, currentFileName)
437
+ } else {
438
+ newPath = expandedPath
439
+ }
440
+
441
+ // Check source file exists
442
+ if (!existsSync(config.database)) {
443
+ console.error(
444
+ error(`Source database file not found: ${config.database}`),
445
+ )
446
+ process.exit(1)
447
+ }
448
+
449
+ // Check if destination already exists
450
+ if (existsSync(newPath)) {
451
+ if (options.overwrite) {
452
+ // Remove existing file before move
453
+ unlinkSync(newPath)
454
+ console.log(warning(`Overwriting existing file: ${newPath}`))
455
+ } else {
456
+ console.error(
457
+ error(`Destination file already exists: ${newPath}`),
458
+ )
459
+ console.log(info('Use --overwrite to replace the existing file'))
460
+ process.exit(1)
461
+ }
462
+ }
463
+
464
+ // Ensure target directory exists
465
+ const targetDir = dirname(newPath)
466
+ if (!existsSync(targetDir)) {
467
+ mkdirSync(targetDir, { recursive: true })
468
+ console.log(info(`Created directory: ${targetDir}`))
469
+ }
470
+
471
+ const spinner = createSpinner(
472
+ `Moving database to ${newPath}...`,
473
+ )
474
+ spinner.start()
475
+
476
+ try {
477
+ // Try rename first (fast, same filesystem)
478
+ try {
479
+ renameSync(config.database, newPath)
480
+ } catch (renameErr) {
481
+ const e = renameErr as NodeJS.ErrnoException
482
+ // EXDEV = cross-device link, need to copy+delete
483
+ if (e.code === 'EXDEV') {
484
+ try {
485
+ // Copy file preserving mode/permissions
486
+ copyFileSync(config.database, newPath)
487
+ // Only delete source after successful copy
488
+ unlinkSync(config.database)
489
+ } catch (copyErr) {
490
+ // Clean up partial target on failure
491
+ if (existsSync(newPath)) {
492
+ try {
493
+ unlinkSync(newPath)
494
+ } catch {
495
+ // Ignore cleanup errors
496
+ }
497
+ }
498
+ throw copyErr
499
+ }
500
+ } else {
501
+ throw renameErr
502
+ }
503
+ }
504
+
505
+ // Update the container config and SQLite registry
506
+ await containerManager.updateConfig(containerName, {
507
+ database: newPath,
508
+ })
509
+ await sqliteRegistry.update(containerName, { filePath: newPath })
510
+
511
+ spinner.succeed(`Database relocated to ${newPath}`)
512
+ } catch (err) {
513
+ spinner.fail('Failed to relocate database')
514
+ throw err
515
+ }
516
+ }
517
+
320
518
  // Handle config change
321
519
  if (options.setConfig) {
322
520
  // Only PostgreSQL supports config editing for now