spindb 0.5.2 → 0.5.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 (36) hide show
  1. package/README.md +137 -8
  2. package/cli/commands/connect.ts +8 -4
  3. package/cli/commands/create.ts +106 -67
  4. package/cli/commands/deps.ts +19 -4
  5. package/cli/commands/edit.ts +245 -0
  6. package/cli/commands/engines.ts +434 -0
  7. package/cli/commands/info.ts +279 -0
  8. package/cli/commands/menu.ts +408 -153
  9. package/cli/commands/restore.ts +10 -24
  10. package/cli/commands/start.ts +25 -20
  11. package/cli/commands/url.ts +79 -0
  12. package/cli/index.ts +9 -3
  13. package/cli/ui/prompts.ts +8 -6
  14. package/config/engine-defaults.ts +24 -1
  15. package/config/os-dependencies.ts +59 -113
  16. package/config/paths.ts +7 -36
  17. package/core/binary-manager.ts +19 -6
  18. package/core/config-manager.ts +17 -5
  19. package/core/dependency-manager.ts +9 -15
  20. package/core/error-handler.ts +336 -0
  21. package/core/platform-service.ts +634 -0
  22. package/core/port-manager.ts +11 -3
  23. package/core/process-manager.ts +12 -2
  24. package/core/start-with-retry.ts +167 -0
  25. package/core/transaction-manager.ts +170 -0
  26. package/engines/mysql/binary-detection.ts +177 -100
  27. package/engines/mysql/index.ts +240 -131
  28. package/engines/mysql/restore.ts +257 -0
  29. package/engines/mysql/version-validator.ts +373 -0
  30. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  31. package/engines/postgresql/binary-urls.ts +5 -3
  32. package/engines/postgresql/index.ts +4 -3
  33. package/engines/postgresql/restore.ts +54 -5
  34. package/engines/postgresql/version-validator.ts +262 -0
  35. package/package.json +6 -2
  36. package/cli/commands/postgres-tools.ts +0 -216
@@ -4,6 +4,7 @@ import { exec } from 'child_process'
4
4
  import { promisify } from 'util'
5
5
  import { dirname } from 'path'
6
6
  import { paths } from '../config/paths'
7
+ import { logDebug, logWarning } from './error-handler'
7
8
  import type {
8
9
  SpinDBConfig,
9
10
  BinaryConfig,
@@ -41,8 +42,12 @@ export class ConfigManager {
41
42
  const content = await readFile(configPath, 'utf8')
42
43
  this.config = JSON.parse(content) as SpinDBConfig
43
44
  return this.config
44
- } catch {
45
+ } catch (error) {
45
46
  // If config is corrupted, reset to default
47
+ logWarning('Config file corrupted, resetting to default', {
48
+ configPath,
49
+ error: error instanceof Error ? error.message : String(error),
50
+ })
46
51
  this.config = { ...DEFAULT_CONFIG }
47
52
  await this.save()
48
53
  return this.config
@@ -110,8 +115,12 @@ export class ConfigManager {
110
115
  if (match) {
111
116
  version = match[0]
112
117
  }
113
- } catch {
114
- // Version detection failed, that's ok
118
+ } catch (error) {
119
+ logDebug('Version detection failed', {
120
+ tool,
121
+ path,
122
+ error: error instanceof Error ? error.message : String(error),
123
+ })
115
124
  }
116
125
 
117
126
  config.binaries[tool] = {
@@ -142,8 +151,11 @@ export class ConfigManager {
142
151
  if (path && existsSync(path)) {
143
152
  return path
144
153
  }
145
- } catch {
146
- // which failed, binary not found
154
+ } catch (error) {
155
+ logDebug('which command failed for binary detection', {
156
+ tool,
157
+ error: error instanceof Error ? error.message : String(error),
158
+ })
147
159
  }
148
160
 
149
161
  // Check common locations
@@ -16,6 +16,7 @@ import {
16
16
  getEngineDependencies,
17
17
  getUniqueDependencies,
18
18
  } from '../config/os-dependencies'
19
+ import { platformService } from './platform-service'
19
20
 
20
21
  const execAsync = promisify(exec)
21
22
 
@@ -46,7 +47,7 @@ export type InstallResult = {
46
47
  * Detect which package manager is available on the current system
47
48
  */
48
49
  export async function detectPackageManager(): Promise<DetectedPackageManager | null> {
49
- const platform = process.platform as Platform
50
+ const { platform } = platformService.getPlatformInfo()
50
51
 
51
52
  // Filter to package managers available on this platform
52
53
  const candidates = packageManagers.filter((pm) =>
@@ -73,7 +74,7 @@ export async function detectPackageManager(): Promise<DetectedPackageManager | n
73
74
  * Get the current platform
74
75
  */
75
76
  export function getCurrentPlatform(): Platform {
76
- return process.platform as Platform
77
+ return platformService.getPlatformInfo().platform as Platform
77
78
  }
78
79
 
79
80
  // =============================================================================
@@ -87,21 +88,12 @@ export async function findBinary(
87
88
  binary: string,
88
89
  ): Promise<{ path: string; version?: string } | null> {
89
90
  try {
90
- const command = process.platform === 'win32' ? 'where' : 'which'
91
- const { stdout } = await execAsync(`${command} ${binary}`)
92
- const path = stdout.trim().split('\n')[0]
93
-
91
+ // Use platformService to find the binary path
92
+ const path = await platformService.findToolPath(binary)
94
93
  if (!path) return null
95
94
 
96
95
  // Try to get version
97
- let version: string | undefined
98
- try {
99
- const { stdout: versionOutput } = await execAsync(`${binary} --version`)
100
- const match = versionOutput.match(/(\d+\.\d+(\.\d+)?)/)
101
- version = match ? match[1] : undefined
102
- } catch {
103
- // Version check failed, that's ok
104
- }
96
+ const version = (await platformService.getToolVersion(path)) || undefined
105
97
 
106
98
  return { path, version }
107
99
  } catch {
@@ -216,7 +208,9 @@ function execWithInheritedStdio(command: string): void {
216
208
  }
217
209
 
218
210
  if (result.status !== 0) {
219
- throw new Error(`Command failed with exit code ${result.status}: ${cmdToRun}`)
211
+ throw new Error(
212
+ `Command failed with exit code ${result.status}: ${cmdToRun}`,
213
+ )
220
214
  }
221
215
  }
222
216
 
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Error Handler
3
+ *
4
+ * Centralized error handling with proper logging and user feedback.
5
+ * - CLI commands log and exit (no blocking for scripts/CI)
6
+ * - Interactive menu uses "Press Enter to continue" pattern
7
+ * - All errors are logged to ~/.spindb/spindb.log for debugging
8
+ */
9
+
10
+ import { appendFileSync, existsSync, mkdirSync } from 'fs'
11
+ import { dirname, join } from 'path'
12
+ import chalk from 'chalk'
13
+
14
+ // Get SpinDB home directory without circular import
15
+ function getSpinDBRoot(): string {
16
+ const home = process.env.HOME || process.env.USERPROFILE || ''
17
+ return join(home, '.spindb')
18
+ }
19
+
20
+ // =============================================================================
21
+ // Types
22
+ // =============================================================================
23
+
24
+ export type ErrorSeverity = 'fatal' | 'error' | 'warning' | 'info'
25
+
26
+ export type SpinDBErrorInfo = {
27
+ code: string
28
+ message: string
29
+ severity: ErrorSeverity
30
+ suggestion?: string
31
+ context?: Record<string, unknown>
32
+ }
33
+
34
+ // =============================================================================
35
+ // Error Codes
36
+ // =============================================================================
37
+
38
+ export const ErrorCodes = {
39
+ // Port errors
40
+ PORT_IN_USE: 'PORT_IN_USE',
41
+ PORT_PERMISSION_DENIED: 'PORT_PERMISSION_DENIED',
42
+ PORT_RANGE_EXHAUSTED: 'PORT_RANGE_EXHAUSTED',
43
+
44
+ // Process errors
45
+ PROCESS_START_FAILED: 'PROCESS_START_FAILED',
46
+ PROCESS_STOP_TIMEOUT: 'PROCESS_STOP_TIMEOUT',
47
+ PROCESS_ALREADY_RUNNING: 'PROCESS_ALREADY_RUNNING',
48
+ PROCESS_NOT_RUNNING: 'PROCESS_NOT_RUNNING',
49
+ PID_FILE_CORRUPT: 'PID_FILE_CORRUPT',
50
+ PID_FILE_STALE: 'PID_FILE_STALE',
51
+ PID_FILE_READ_FAILED: 'PID_FILE_READ_FAILED',
52
+
53
+ // Restore errors
54
+ VERSION_MISMATCH: 'VERSION_MISMATCH',
55
+ RESTORE_PARTIAL_FAILURE: 'RESTORE_PARTIAL_FAILURE',
56
+ RESTORE_COMPLETE_FAILURE: 'RESTORE_COMPLETE_FAILURE',
57
+ BACKUP_FORMAT_UNKNOWN: 'BACKUP_FORMAT_UNKNOWN',
58
+ WRONG_ENGINE_DUMP: 'WRONG_ENGINE_DUMP',
59
+
60
+ // Container errors
61
+ CONTAINER_NOT_FOUND: 'CONTAINER_NOT_FOUND',
62
+ CONTAINER_ALREADY_EXISTS: 'CONTAINER_ALREADY_EXISTS',
63
+ CONTAINER_RUNNING: 'CONTAINER_RUNNING',
64
+ CONTAINER_CREATE_FAILED: 'CONTAINER_CREATE_FAILED',
65
+ INIT_FAILED: 'INIT_FAILED',
66
+ DATABASE_CREATE_FAILED: 'DATABASE_CREATE_FAILED',
67
+
68
+ // Dependency errors
69
+ DEPENDENCY_MISSING: 'DEPENDENCY_MISSING',
70
+ DEPENDENCY_VERSION_INCOMPATIBLE: 'DEPENDENCY_VERSION_INCOMPATIBLE',
71
+
72
+ // Rollback errors
73
+ ROLLBACK_FAILED: 'ROLLBACK_FAILED',
74
+
75
+ // Clipboard errors
76
+ CLIPBOARD_FAILED: 'CLIPBOARD_FAILED',
77
+
78
+ // General errors
79
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR',
80
+ FILE_NOT_FOUND: 'FILE_NOT_FOUND',
81
+ PERMISSION_DENIED: 'PERMISSION_DENIED',
82
+ } as const
83
+
84
+ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]
85
+
86
+ // =============================================================================
87
+ // SpinDBError Class
88
+ // =============================================================================
89
+
90
+ export class SpinDBError extends Error {
91
+ public readonly code: string
92
+ public readonly severity: ErrorSeverity
93
+ public readonly suggestion?: string
94
+ public readonly context?: Record<string, unknown>
95
+
96
+ constructor(
97
+ code: string,
98
+ message: string,
99
+ severity: ErrorSeverity = 'error',
100
+ suggestion?: string,
101
+ context?: Record<string, unknown>,
102
+ ) {
103
+ super(message)
104
+ this.name = 'SpinDBError'
105
+ this.code = code
106
+ this.severity = severity
107
+ this.suggestion = suggestion
108
+ this.context = context
109
+
110
+ // Capture proper stack trace
111
+ Error.captureStackTrace(this, SpinDBError)
112
+ }
113
+
114
+ /**
115
+ * Create SpinDBError from an unknown error
116
+ */
117
+ static from(
118
+ error: unknown,
119
+ code: string = ErrorCodes.UNKNOWN_ERROR,
120
+ suggestion?: string,
121
+ ): SpinDBError {
122
+ if (error instanceof SpinDBError) {
123
+ return error
124
+ }
125
+
126
+ const message = error instanceof Error ? error.message : String(error)
127
+
128
+ return new SpinDBError(code, message, 'error', suggestion, {
129
+ originalError: error instanceof Error ? error.stack : undefined,
130
+ })
131
+ }
132
+ }
133
+
134
+ // =============================================================================
135
+ // Logging Functions
136
+ // =============================================================================
137
+
138
+ /**
139
+ * Get the path to the log file
140
+ */
141
+ function getLogPath(): string {
142
+ return join(getSpinDBRoot(), 'spindb.log')
143
+ }
144
+
145
+ /**
146
+ * Ensure the log directory exists
147
+ */
148
+ function ensureLogDirectory(): void {
149
+ const logPath = getLogPath()
150
+ const logDir = dirname(logPath)
151
+ if (!existsSync(logDir)) {
152
+ mkdirSync(logDir, { recursive: true })
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Append a structured log entry to the log file
158
+ */
159
+ function appendToLogFile(entry: SpinDBErrorInfo): void {
160
+ try {
161
+ ensureLogDirectory()
162
+ const logPath = getLogPath()
163
+ const logEntry = {
164
+ timestamp: new Date().toISOString(),
165
+ ...entry,
166
+ }
167
+ appendFileSync(logPath, JSON.stringify(logEntry) + '\n')
168
+ } catch {
169
+ // If we can't write to log file, don't fail the operation
170
+ // This could happen if ~/.spindb doesn't exist yet
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Format severity for console output
176
+ */
177
+ function formatSeverity(severity: ErrorSeverity): string {
178
+ switch (severity) {
179
+ case 'fatal':
180
+ return chalk.red.bold('[FATAL]')
181
+ case 'error':
182
+ return chalk.red('[ERROR]')
183
+ case 'warning':
184
+ return chalk.yellow('[WARN]')
185
+ case 'info':
186
+ return chalk.blue('[INFO]')
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Log an error to console and log file
192
+ * This is for CLI commands - displays error and returns (no blocking)
193
+ */
194
+ export function logError(error: SpinDBErrorInfo): void {
195
+ // Console output with colors
196
+ const prefix = formatSeverity(error.severity)
197
+ console.error(`${prefix} [${error.code}] ${error.message}`)
198
+
199
+ if (error.suggestion) {
200
+ console.error(chalk.yellow(` Suggestion: ${error.suggestion}`))
201
+ }
202
+
203
+ // Also append to log file for headless debugging
204
+ appendToLogFile(error)
205
+ }
206
+
207
+ /**
208
+ * Log a SpinDBError instance
209
+ */
210
+ export function logSpinDBError(error: SpinDBError): void {
211
+ logError({
212
+ code: error.code,
213
+ message: error.message,
214
+ severity: error.severity,
215
+ suggestion: error.suggestion,
216
+ context: error.context,
217
+ })
218
+ }
219
+
220
+ /**
221
+ * Log a warning (non-blocking, yellow output)
222
+ */
223
+ export function logWarning(
224
+ message: string,
225
+ context?: Record<string, unknown>,
226
+ ): void {
227
+ console.warn(chalk.yellow(` ⚠ ${message}`))
228
+
229
+ appendToLogFile({
230
+ code: 'WARNING',
231
+ message,
232
+ severity: 'warning',
233
+ context,
234
+ })
235
+ }
236
+
237
+ /**
238
+ * Log an info message
239
+ */
240
+ export function logInfo(
241
+ message: string,
242
+ context?: Record<string, unknown>,
243
+ ): void {
244
+ appendToLogFile({
245
+ code: 'INFO',
246
+ message,
247
+ severity: 'info',
248
+ context,
249
+ })
250
+ }
251
+
252
+ /**
253
+ * Log a debug message (only to file, not console)
254
+ */
255
+ export function logDebug(
256
+ message: string,
257
+ context?: Record<string, unknown>,
258
+ ): void {
259
+ appendToLogFile({
260
+ code: 'DEBUG',
261
+ message,
262
+ severity: 'info',
263
+ context,
264
+ })
265
+ }
266
+
267
+ // =============================================================================
268
+ // Error Creation Helpers
269
+ // =============================================================================
270
+
271
+ /**
272
+ * Create a port-in-use error with helpful suggestion
273
+ */
274
+ export function createPortInUseError(port: number): SpinDBError {
275
+ return new SpinDBError(
276
+ ErrorCodes.PORT_IN_USE,
277
+ `Port ${port} is already in use`,
278
+ 'error',
279
+ `Use a different port with -p flag, or stop the process using port ${port}`,
280
+ { port },
281
+ )
282
+ }
283
+
284
+ /**
285
+ * Create a container-not-found error
286
+ */
287
+ export function createContainerNotFoundError(name: string): SpinDBError {
288
+ return new SpinDBError(
289
+ ErrorCodes.CONTAINER_NOT_FOUND,
290
+ `Container "${name}" not found`,
291
+ 'error',
292
+ 'Run "spindb list" to see available containers',
293
+ { containerName: name },
294
+ )
295
+ }
296
+
297
+ /**
298
+ * Create a version mismatch error for pg_restore
299
+ */
300
+ export function createVersionMismatchError(
301
+ dumpVersion: string,
302
+ toolVersion: string,
303
+ ): SpinDBError {
304
+ return new SpinDBError(
305
+ ErrorCodes.VERSION_MISMATCH,
306
+ `Backup was created with PostgreSQL ${dumpVersion}, but your pg_restore is version ${toolVersion}`,
307
+ 'fatal',
308
+ `Install PostgreSQL ${dumpVersion} client tools: brew install postgresql@${dumpVersion}`,
309
+ { dumpVersion, toolVersion },
310
+ )
311
+ }
312
+
313
+ /**
314
+ * Create a dependency missing error
315
+ */
316
+ export function createDependencyMissingError(
317
+ toolName: string,
318
+ engine: string,
319
+ ): SpinDBError {
320
+ const suggestions: Record<string, string> = {
321
+ psql: 'brew install libpq && brew link --force libpq',
322
+ pg_dump: 'brew install libpq && brew link --force libpq',
323
+ pg_restore: 'brew install libpq && brew link --force libpq',
324
+ mysql: 'brew install mysql-client',
325
+ mysqldump: 'brew install mysql-client',
326
+ mysqld: 'brew install mysql',
327
+ }
328
+
329
+ return new SpinDBError(
330
+ ErrorCodes.DEPENDENCY_MISSING,
331
+ `${toolName} not found`,
332
+ 'error',
333
+ suggestions[toolName] || `Install ${engine} client tools`,
334
+ { toolName, engine },
335
+ )
336
+ }