diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 48f9b5c..d432f69 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -136,6 +136,12 @@ func runAuthLogin(opts *AuthLoginOptions) error { Hint: "Check your email and API key at Profile > API Key in DeployHQ", } } + if sdk.IsForbidden(err) { + return &output.AuthError{ + Message: "Access denied", + Hint: "Your account may have API access restricted, or your user may not be a member of this account. Check Account Settings > API.", + } + } if output.IsNetworkErr(err) { return &output.NetworkError{Message: "validate credentials", Cause: err} } diff --git a/internal/output/breadcrumbs.go b/internal/output/breadcrumbs.go index a6d0e57..1aff631 100644 --- a/internal/output/breadcrumbs.go +++ b/internal/output/breadcrumbs.go @@ -1,6 +1,12 @@ package output -import "strings" +import ( + "errors" + "net/http" + "strings" + + "github.com/deployhq/deployhq-cli/pkg/sdk" +) // Breadcrumb represents a suggested next action. type Breadcrumb struct { @@ -98,6 +104,31 @@ func ErrorResponseFromErr(err error) *Response { case *InternalError: code = "internal_error" message = e.Message + case *NetworkError: + code = "network_error" + message = e.Message + } + + // Mirror the status-code mapping from ClassifyError so a raw *sdk.APIError + // returned without wrapping produces a `code` field consistent with the + // exit_code agents already follow. + if code == "error" { + var apiErr *sdk.APIError + if errors.As(err, &apiErr) { + switch { + case apiErr.StatusCode == http.StatusUnauthorized, + apiErr.StatusCode == http.StatusForbidden: + code = "auth_error" + case apiErr.StatusCode == http.StatusNotFound: + code = "not_found" + case apiErr.StatusCode == http.StatusConflict: + code = "conflict" + case apiErr.StatusCode >= 400 && apiErr.StatusCode < 500: + code = "user_error" + case apiErr.StatusCode >= 500: + code = "internal_error" + } + } } // Enrich errors with actionable suggestions when no hint is set diff --git a/internal/output/breadcrumbs_test.go b/internal/output/breadcrumbs_test.go index 126c35b..09f16b8 100644 --- a/internal/output/breadcrumbs_test.go +++ b/internal/output/breadcrumbs_test.go @@ -2,8 +2,10 @@ package output import ( "encoding/json" + "net/http" "testing" + "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -116,6 +118,37 @@ func TestErrorResponseFromErr_Retryable(t *testing.T) { assert.True(t, data.Retryable) } +func TestErrorResponseFromErr_APIErrorByStatus(t *testing.T) { + cases := []struct { + status int + wantCode string + wantExit int + }{ + {401, "auth_error", ExitAuthError}, + {403, "auth_error", ExitAuthError}, + {404, "not_found", ExitNotFoundError}, + {409, "conflict", ExitConflictError}, + {422, "user_error", ExitUserError}, + {500, "internal_error", ExitInternalError}, + {503, "internal_error", ExitInternalError}, + } + for _, tc := range cases { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + err := &sdk.APIError{StatusCode: tc.status, Message: "x"} + data := ErrorResponseFromErr(err).Data.(ErrorData) + assert.Equal(t, tc.wantCode, data.Code, "code mismatch") + assert.Equal(t, tc.wantExit, data.ExitCode, "exit_code mismatch") + }) + } +} + +func TestErrorResponseFromErr_NetworkError(t *testing.T) { + err := &NetworkError{Message: "validate credentials"} + data := ErrorResponseFromErr(err).Data.(ErrorData) + assert.Equal(t, "network_error", data.Code) + assert.Equal(t, ExitNetworkError, data.ExitCode) +} + func TestErrorData_JSONShape(t *testing.T) { resp := ErrorResponseFromErr(&UserError{Message: "test error"}) diff --git a/internal/output/errors.go b/internal/output/errors.go index 81af8fd..e984caa 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -10,8 +10,11 @@ import ( "errors" "fmt" "net" + "net/http" "net/url" "syscall" + + "github.com/deployhq/deployhq-cli/pkg/sdk" ) // ExitCode constants for error classification. @@ -121,6 +124,11 @@ func IsNetworkErr(err error) bool { } // ClassifyError returns the appropriate exit code for an error. +// +// Precedence: typed errors (UserError, InternalError, etc.) the command code +// chose explicitly always win. Raw *sdk.APIError values returned without +// wrapping fall back to a status-code mapping so 4xx doesn't get reported as +// an internal CLI bug. func ClassifyError(err error) int { if err == nil { return ExitOK @@ -135,6 +143,22 @@ func ClassifyError(err error) int { case *NetworkError: return ExitNetworkError } + var apiErr *sdk.APIError + if errors.As(err, &apiErr) { + switch { + case apiErr.StatusCode == http.StatusUnauthorized, + apiErr.StatusCode == http.StatusForbidden: + return ExitAuthError + case apiErr.StatusCode == http.StatusNotFound: + return ExitNotFoundError + case apiErr.StatusCode == http.StatusConflict: + return ExitConflictError + case apiErr.StatusCode >= 400 && apiErr.StatusCode < 500: + return ExitUserError + case apiErr.StatusCode >= 500: + return ExitInternalError + } + } if IsNetworkErr(err) { return ExitNetworkError } diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go index dbe51b7..5b4c418 100644 --- a/internal/output/errors_test.go +++ b/internal/output/errors_test.go @@ -2,12 +2,14 @@ package output import ( "errors" + "fmt" "net" "net/url" "syscall" "testing" "time" + "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/stretchr/testify/assert" ) @@ -132,6 +134,35 @@ func TestClassifyError_DetectsURLNetworkError(t *testing.T) { assert.Equal(t, ExitNetworkError, ClassifyError(urlErr)) } +func TestClassifyError_APIErrorByStatus(t *testing.T) { + cases := []struct { + status int + want int + name string + }{ + {401, ExitAuthError, "401 unauthorized"}, + {403, ExitAuthError, "403 forbidden"}, + {404, ExitNotFoundError, "404 not found uses dedicated exit code"}, + {409, ExitConflictError, "409 conflict uses dedicated exit code"}, + {422, ExitUserError, "422 validation is bad input"}, + {500, ExitInternalError, "500 server is internal"}, + {503, ExitInternalError, "503 unavailable is internal"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := &sdk.APIError{StatusCode: tc.status, Message: "x"} + assert.Equal(t, tc.want, ClassifyError(err)) + }) + } +} + +func TestClassifyError_APIErrorWrapped(t *testing.T) { + // errors.As should unwrap through a wrapping error to find the APIError. + apiErr := &sdk.APIError{StatusCode: 404, Message: "not found"} + wrapped := fmt.Errorf("fetch project: %w", apiErr) + assert.Equal(t, ExitNotFoundError, ClassifyError(wrapped)) +} + // Sanity check: a deadline-style error wrapped in net.Error.Timeout() classifies // even when accessed through deadline-exceeded chains used by net/http. func TestIsNetworkErr_DeadlineExceededViaNetError(t *testing.T) { diff --git a/pkg/sdk/errors.go b/pkg/sdk/errors.go index 8f2d224..0012f1f 100644 --- a/pkg/sdk/errors.go +++ b/pkg/sdk/errors.go @@ -69,3 +69,11 @@ func IsUnauthorized(err error) bool { } return false } + +// IsForbidden checks whether err is an APIError with status 403. +func IsForbidden(err error) bool { + if apiErr, ok := err.(*APIError); ok { + return apiErr.IsForbidden() + } + return false +}