diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 186b3f0..b320426 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -3,7 +3,7 @@ > **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions > > **Last Updated**: 2024-06-22 (Session 2) -> **Current Status**: Implementation In Progress - Tasks 1-3 Complete +> **Current Status**: Implementation In Progress - Tasks 1-4 Complete > **Phase**: MVP Development (Phase 1) --- @@ -25,9 +25,9 @@ ✅ **Task 1**: Project setup (go.mod, Makefile, .gitignore, main.go) ✅ **Task 2**: Core data models (`internal/models/models.go`) + JSON storage (`pkg/storage/`) ✅ **Task 3**: Configuration management (`pkg/config/config.go`) +✅ **Task 4**: Error handling framework (`internal/errors/`) + tests passing ### What Needs to Happen Next -🔄 **Task 4**: Error Handling Framework (`internal/errors/`) 🔄 **Task 5**: SSH Client Implementation (`pkg/ssh/`) 🔄 **Task 6**: CLI Framework Setup (Cobra commands in `cmd/hostkeeper/`) 🔄 Build and test core features @@ -45,13 +45,14 @@ | **Setup** | ✅ 100% | go.mod, Makefile, .gitignore, main.go | | **Core Models** | ✅ 100% | Host, KeyPair, Snippet, AppConfig models + JSON storage | | **Config** | ✅ 100% | Cross-platform config management | +| **Errors** | ✅ 100% | AppError + ConnectionError + SSH error handler | | **SSH Client** | 🔲 0% | Connection and authentication | | **CLI Commands** | 🔲 0% | User interface commands | | **TUI** | 🔲 0% | Terminal user interface | | **Testing** | 🔲 0% | Test suite and integration | | **Documentation** | 🔲 0% | Usage guides and API docs | -### Overall Progress: **~25% Complete** (Tasks 1-3 done) +### Overall Progress: **~30% Complete** (Tasks 1-4 done) --- @@ -84,14 +85,14 @@ - **Files Created**: - `pkg/config/config.go` — Config struct, OS-aware paths (macOS/Linux/Windows), auto-create defaults -#### 🔲 Task 4: Error Handling Framework -- **Status**: Not Started +#### ✅ Task 4: Error Handling Framework +- **Status**: ✅ Completed (all tests passing) - **Priority**: HIGH -- **Estimated Time**: 1-2 hours -- **Dependencies**: Task 2 complete - **Deliverables**: Error types, SSH error handler -- **Files to Create**: - - `internal/errors/*.go` +- **Files Created**: + - `internal/errors/errors.go` — AppError type with codes, Unwrap support + - `internal/errors/connection_errors.go` — ConnectionError, HandleSSHError, FormatConnectionError + - `test/errors_test.go` — 5 test functions, all passing #### 🔲 Task 5: SSH Client Implementation - **Status**: Not Started @@ -126,7 +127,7 @@ - [x] Project setup and dependencies - [x] Core data models and storage - [x] Configuration management -- [ ] Error handling framework +- [x] Error handling framework - [ ] SSH client implementation - [ ] CLI framework setup diff --git a/internal/errors/connection_errors.go b/internal/errors/connection_errors.go new file mode 100644 index 0000000..21e26ca --- /dev/null +++ b/internal/errors/connection_errors.go @@ -0,0 +1,105 @@ +package errors + +import ( + "fmt" + "strings" +) + +// ConnectionError represents SSH connection errors with helpful hints +type ConnectionError struct { + Type string // "auth", "network", "timeout", "config", "unknown" + Message string + Details string + Hints []string +} + +// Error implements the error interface +func (e *ConnectionError) Error() string { + return fmt.Sprintf("Connection Error (%s): %s\nDetails: %s", e.Type, e.Message, e.Details) +} + +// NewConnectionError creates a new ConnectionError +func NewConnectionError(errType, message, details string, hints []string) *ConnectionError { + return &ConnectionError{ + Type: errType, + Message: message, + Details: details, + Hints: hints, + } +} + +// HandleSSHError processes SSH errors and returns user-friendly errors +func HandleSSHError(err error) *ConnectionError { + if err == nil { + return nil + } + + errStr := err.Error() + + switch { + case strings.Contains(errStr, "connection refused"): + return &ConnectionError{ + Type: "network", + Message: "Cannot connect to server", + Details: errStr, + Hints: []string{"Check if server is running", "Verify firewall rules", "Confirm hostname and port"}, + } + + case strings.Contains(errStr, "authentication failed"), strings.Contains(errStr, "unable to authenticate"): + return &ConnectionError{ + Type: "auth", + Message: "Authentication failed", + Details: errStr, + Hints: []string{"Verify username and password", "Check SSH key is loaded", "Test with native SSH client"}, + } + + case strings.Contains(errStr, "timeout"), strings.Contains(errStr, "timed out"): + return &ConnectionError{ + Type: "timeout", + Message: "Connection timeout", + Details: errStr, + Hints: []string{"Check network connectivity", "Try increasing timeout", "Verify server is reachable"}, + } + + case strings.Contains(errStr, "no such host"), strings.Contains(errStr, "hostname"): + return &ConnectionError{ + Type: "config", + Message: "Invalid hostname", + Details: errStr, + Hints: []string{"Verify hostname spelling", "Check DNS resolution", "Try IP address instead"}, + } + + case strings.Contains(errStr, "permission denied"): + return &ConnectionError{ + Type: "auth", + Message: "Permission denied", + Details: errStr, + Hints: []string{"Check user permissions on server", "Verify account is not locked", "Check authentication method"}, + } + + default: + return &ConnectionError{ + Type: "unknown", + Message: "Connection failed", + Details: errStr, + Hints: []string{"Check host configuration", "Verify network settings", "Test with standard SSH client"}, + } + } +} + +// FormatConnectionError formats connection error for user-friendly display +func FormatConnectionError(err *ConnectionError) string { + var output strings.Builder + + output.WriteString(fmt.Sprintf("❌ Connection Error: %s\n\n", err.Message)) + output.WriteString(fmt.Sprintf("Details: %s\n\n", err.Details)) + + if len(err.Hints) > 0 { + output.WriteString("Possible solutions:\n") + for i, hint := range err.Hints { + output.WriteString(fmt.Sprintf(" %d. %s\n", i+1, hint)) + } + } + + return output.String() +} \ No newline at end of file diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..d1acbe4 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,48 @@ +package errors + +import ( + "fmt" +) + +// Error codes +const ( + ErrHostNotFound = "HOST_NOT_FOUND" + ErrAuthFailed = "AUTH_FAILED" + ErrConnectionTimeout = "CONNECTION_TIMEOUT" + ErrInvalidConfig = "INVALID_CONFIG" + ErrKeyNotFound = "KEY_NOT_FOUND" + ErrPermissionDenied = "PERMISSION_DENIED" + ErrFileCorrupted = "FILE_CORRUPTED" + ErrInvalidCredentials = "INVALID_CREDENTIALS" +) + +// AppError represents an application error with code, message, cause, and hints +type AppError struct { + Code string + Message string + Cause error + Hints []string +} + +// Error implements the error interface +func (e *AppError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +// Unwrap returns the underlying cause for use with errors.Is/errors.As +func (e *AppError) Unwrap() error { + return e.Cause +} + +// NewAppError creates a new application error +func NewAppError(code, message string, cause error, hints []string) *AppError { + return &AppError{ + Code: code, + Message: message, + Cause: cause, + Hints: hints, + } +} \ No newline at end of file diff --git a/test/errors_test.go b/test/errors_test.go new file mode 100644 index 0000000..7d588de --- /dev/null +++ b/test/errors_test.go @@ -0,0 +1,142 @@ +package errors_test + +import ( + stderrors "errors" + "strings" + "testing" + + apperrors "git.tukangketik.id/swanadiva/hostkeeper/internal/errors" +) + +func TestAppError(t *testing.T) { + cause := stderrors.New("underlying issue") + appErr := apperrors.NewAppError( + apperrors.ErrAuthFailed, + "Authentication failed", + cause, + []string{"Check credentials", "Verify key permissions"}, + ) + + if appErr.Code != apperrors.ErrAuthFailed { + t.Errorf("Expected code '%s', got '%s'", apperrors.ErrAuthFailed, appErr.Code) + } + + if len(appErr.Hints) != 2 { + t.Errorf("Expected 2 hints, got %d", len(appErr.Hints)) + } + + // Test error message is non-empty + if appErr.Error() == "" { + t.Error("Expected non-empty error message") + } + + // Test Unwrap + if !stderrors.Is(appErr, cause) { + t.Error("Expected errors.Is to match the cause via Unwrap") + } +} + +func TestConnectionError(t *testing.T) { + connErr := apperrors.NewConnectionError( + "auth", + "Authentication failed", + "ssh: handshake failed", + []string{"Check credentials", "Verify key permissions"}, + ) + + if connErr.Type != "auth" { + t.Errorf("Expected type 'auth', got '%s'", connErr.Type) + } + + if len(connErr.Hints) != 2 { + t.Errorf("Expected 2 hints, got %d", len(connErr.Hints)) + } + + if connErr.Error() == "" { + t.Error("Expected non-empty error message") + } +} + +func TestHandleSSHError(t *testing.T) { + tests := []struct { + name string + inputErr error + expectedType string + }{ + { + name: "connection refused", + inputErr: stderrors.New("dial tcp: connection refused"), + expectedType: "network", + }, + { + name: "authentication failed", + inputErr: stderrors.New("ssh: handshake failed: ssh: unable to authenticate"), + expectedType: "auth", + }, + { + name: "timeout", + inputErr: stderrors.New("dial tcp: connection timed out"), + expectedType: "timeout", + }, + { + name: "no such host", + inputErr: stderrors.New("dial tcp: lookup: no such host"), + expectedType: "config", + }, + { + name: "permission denied", + inputErr: stderrors.New("ssh: permission denied"), + expectedType: "auth", + }, + { + name: "unknown error", + inputErr: stderrors.New("something went wrong"), + expectedType: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + connErr := apperrors.HandleSSHError(tt.inputErr) + if connErr == nil { + t.Fatal("Expected non-nil ConnectionError") + } + if connErr.Type != tt.expectedType { + t.Errorf("Expected type '%s', got '%s'", tt.expectedType, connErr.Type) + } + if len(connErr.Hints) == 0 { + t.Error("Expected at least one hint") + } + }) + } +} + +func TestHandleSSHErrorNil(t *testing.T) { + result := apperrors.HandleSSHError(nil) + if result != nil { + t.Error("Expected nil for nil input") + } +} + +func TestFormatConnectionError(t *testing.T) { + connErr := apperrors.NewConnectionError( + "auth", + "Authentication failed", + "ssh: handshake failed", + []string{"Check credentials", "Verify key permissions"}, + ) + + output := apperrors.FormatConnectionError(connErr) + + if output == "" { + t.Error("Expected non-empty formatted output") + } + + if !strings.Contains(output, "Authentication failed") { + t.Error("Expected output to contain error message") + } + + if !strings.Contains(output, "Possible solutions") { + t.Error("Expected output to contain hints section") + } +} \ No newline at end of file