feat: Implement error handling framework (Task 4)
- Add AppError with error codes and hints system - Implement ConnectionError for SSH-specific errors - Add HandleSSHError for intelligent SSH error parsing - Add FormatConnectionError for user-friendly output - All 5 test functions passing (TestAppError, TestConnectionError, TestHandleSSHError, TestHandleSSHErrorNil, TestFormatConnectionError) - Update PROJECT_STATE.md to reflect Task 4 completion Progress: Tasks 1-4 complete (~30%)
This commit is contained in:
+11
-10
@@ -3,7 +3,7 @@
|
|||||||
> **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions
|
> **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions
|
||||||
>
|
>
|
||||||
> **Last Updated**: 2024-06-22 (Session 2)
|
> **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)
|
> **Phase**: MVP Development (Phase 1)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
✅ **Task 1**: Project setup (go.mod, Makefile, .gitignore, main.go)
|
✅ **Task 1**: Project setup (go.mod, Makefile, .gitignore, main.go)
|
||||||
✅ **Task 2**: Core data models (`internal/models/models.go`) + JSON storage (`pkg/storage/`)
|
✅ **Task 2**: Core data models (`internal/models/models.go`) + JSON storage (`pkg/storage/`)
|
||||||
✅ **Task 3**: Configuration management (`pkg/config/config.go`)
|
✅ **Task 3**: Configuration management (`pkg/config/config.go`)
|
||||||
|
✅ **Task 4**: Error handling framework (`internal/errors/`) + tests passing
|
||||||
|
|
||||||
### What Needs to Happen Next
|
### What Needs to Happen Next
|
||||||
🔄 **Task 4**: Error Handling Framework (`internal/errors/`)
|
|
||||||
🔄 **Task 5**: SSH Client Implementation (`pkg/ssh/`)
|
🔄 **Task 5**: SSH Client Implementation (`pkg/ssh/`)
|
||||||
🔄 **Task 6**: CLI Framework Setup (Cobra commands in `cmd/hostkeeper/`)
|
🔄 **Task 6**: CLI Framework Setup (Cobra commands in `cmd/hostkeeper/`)
|
||||||
🔄 Build and test core features
|
🔄 Build and test core features
|
||||||
@@ -45,13 +45,14 @@
|
|||||||
| **Setup** | ✅ 100% | go.mod, Makefile, .gitignore, main.go |
|
| **Setup** | ✅ 100% | go.mod, Makefile, .gitignore, main.go |
|
||||||
| **Core Models** | ✅ 100% | Host, KeyPair, Snippet, AppConfig models + JSON storage |
|
| **Core Models** | ✅ 100% | Host, KeyPair, Snippet, AppConfig models + JSON storage |
|
||||||
| **Config** | ✅ 100% | Cross-platform config management |
|
| **Config** | ✅ 100% | Cross-platform config management |
|
||||||
|
| **Errors** | ✅ 100% | AppError + ConnectionError + SSH error handler |
|
||||||
| **SSH Client** | 🔲 0% | Connection and authentication |
|
| **SSH Client** | 🔲 0% | Connection and authentication |
|
||||||
| **CLI Commands** | 🔲 0% | User interface commands |
|
| **CLI Commands** | 🔲 0% | User interface commands |
|
||||||
| **TUI** | 🔲 0% | Terminal user interface |
|
| **TUI** | 🔲 0% | Terminal user interface |
|
||||||
| **Testing** | 🔲 0% | Test suite and integration |
|
| **Testing** | 🔲 0% | Test suite and integration |
|
||||||
| **Documentation** | 🔲 0% | Usage guides and API docs |
|
| **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**:
|
- **Files Created**:
|
||||||
- `pkg/config/config.go` — Config struct, OS-aware paths (macOS/Linux/Windows), auto-create defaults
|
- `pkg/config/config.go` — Config struct, OS-aware paths (macOS/Linux/Windows), auto-create defaults
|
||||||
|
|
||||||
#### 🔲 Task 4: Error Handling Framework
|
#### ✅ Task 4: Error Handling Framework
|
||||||
- **Status**: Not Started
|
- **Status**: ✅ Completed (all tests passing)
|
||||||
- **Priority**: HIGH
|
- **Priority**: HIGH
|
||||||
- **Estimated Time**: 1-2 hours
|
|
||||||
- **Dependencies**: Task 2 complete
|
|
||||||
- **Deliverables**: Error types, SSH error handler
|
- **Deliverables**: Error types, SSH error handler
|
||||||
- **Files to Create**:
|
- **Files Created**:
|
||||||
- `internal/errors/*.go`
|
- `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
|
#### 🔲 Task 5: SSH Client Implementation
|
||||||
- **Status**: Not Started
|
- **Status**: Not Started
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
- [x] Project setup and dependencies
|
- [x] Project setup and dependencies
|
||||||
- [x] Core data models and storage
|
- [x] Core data models and storage
|
||||||
- [x] Configuration management
|
- [x] Configuration management
|
||||||
- [ ] Error handling framework
|
- [x] Error handling framework
|
||||||
- [ ] SSH client implementation
|
- [ ] SSH client implementation
|
||||||
- [ ] CLI framework setup
|
- [ ] CLI framework setup
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user