Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ builds:
flags: [-trimpath]
ldflags:
- -s -w
- -X github.com/major-technology/cli/cmd.version={{.Version}}
- -X github.com/major-technology/cli/cmd.Version={{.Version}}
- -X github.com/major-technology/cli/cmd.configFile=configs/prod.json
goos: [darwin, linux]
goarch: [amd64, arm64]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ brew install major-technology/tap/major

### Direct Install
```bash
curl -fsSL https://raw.githubusercontent.com/major-technology/cli/main/install.sh | bash
curl -fsSL https://install.major.build | bash
```

### Updating
Expand Down
39 changes: 14 additions & 25 deletions clients/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

mjrToken "github.com/major-technology/cli/clients/token"
clierrors "github.com/major-technology/cli/errors"
"github.com/pkg/errors"
)

Expand Down Expand Up @@ -46,7 +47,8 @@ func (c *Client) doRequestInternal(method, path string, body interface{}, respon
// Get token from keyring for this request
t, err := mjrToken.GetToken()
if err != nil {
return &NoTokenError{OriginalError: err}
// User is not logged in - return appropriate CLIError
return clierrors.ErrorNotLoggedIn
}
token = t
}
Expand Down Expand Up @@ -84,20 +86,19 @@ func (c *Client) doRequestInternal(method, path string, body interface{}, respon

// Handle error responses
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errResp ErrorResponse
var errResp *ErrorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error != nil {
return &APIError{
StatusCode: errResp.Error.StatusCode,
InternalCode: errResp.Error.InternalCode,
Message: errResp.Error.ErrorString,
ErrorType: errResp.Error.ErrorString,
}
return ToCLIError(errResp)
}
// Fallback for unexpected error format
return &APIError{
StatusCode: resp.StatusCode,
Message: string(respBody),
errResp = &ErrorResponse{
Error: &AppErrorDetail{
InternalCode: 9999,
ErrorString: string(respBody),
StatusCode: resp.StatusCode,
},
}
return ToCLIError(errResp)
}

// Parse successful response if a response struct is provided
Expand All @@ -123,22 +124,12 @@ func (c *Client) StartLogin() (*LoginStartResponse, error) {
}

// PollLogin polls the login endpoint to check if the user has authorized the device
// Returns the response and error. For authorization pending state, returns a specific error.
func (c *Client) PollLogin(deviceCode string) (*LoginPollResponse, error) {
req := LoginPollRequest{DeviceCode: deviceCode}

var resp LoginPollResponse
err := c.doRequestWithoutAuth("POST", "/login/poll", req, &resp)
if err != nil {
// Check if authorization is pending (expected error state)
if IsAuthorizationPending(err) {
return nil, err // Return the error so caller can check with IsAuthorizationPending
}
// Check if it's an invalid device code
if IsInvalidDeviceCode(err) {
return nil, fmt.Errorf("invalid or expired device code")
}
// Other errors
return nil, err
}

Expand All @@ -150,14 +141,11 @@ func (c *Client) VerifyToken() (*VerifyTokenResponse, error) {
var resp VerifyTokenResponse
err := c.doRequest("GET", "/verify", nil, &resp)
if err != nil {
if IsUnauthorized(err) {
return nil, fmt.Errorf("invalid or expired token - please login again")
}
return nil, err
}

if !resp.Active {
return nil, fmt.Errorf("token is not active - please login again")
return nil, clierrors.ErrorTokenNotActive
}

return &resp, nil
Expand Down Expand Up @@ -363,6 +351,7 @@ func (c *Client) SetApplicationTemplate(applicationID, templateID string) (*SetA
// CheckVersion checks if the CLI version is up to date
func (c *Client) CheckVersion(currentVersion string) (*CheckVersionResponse, error) {
req := VersionCheckRequest{Version: currentVersion}

var resp CheckVersionResponse
err := c.doRequestWithoutAuth("POST", "/version/check", req, &resp)
if err != nil {
Expand Down
175 changes: 36 additions & 139 deletions clients/api/errors.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
package api

import (
"errors"
"fmt"
"net/http"

"github.com/major-technology/cli/ui"
"github.com/spf13/cobra"
clierrors "github.com/major-technology/cli/errors"
)

// Error code constants from @repo/errors
Expand Down Expand Up @@ -47,145 +44,45 @@ type ErrorResponse struct {
Error *AppErrorDetail `json:"error,omitempty"`
}

// APIError represents an API error with status code and message
type APIError struct {
StatusCode int
InternalCode int // Internal error code from the API
Message string
ErrorType string
}

func (e *APIError) Error() string {
if e.ErrorType != "" {
return fmt.Sprintf("API error (status %d): %s - %s", e.StatusCode, e.ErrorType, e.Message)
}
return fmt.Sprintf("API error (status %d): %s", e.StatusCode, e.Message)
}

// NoTokenError represents an error when no token is available
type NoTokenError struct {
OriginalError error
}

func (e *NoTokenError) Error() string {
return fmt.Sprintf("not logged in: %v", e.OriginalError)
}

// ForceUpgradeError represents an error when the CLI version is too old and must be upgraded
type ForceUpgradeError struct {
LatestVersion string
}

func (e *ForceUpgradeError) Error() string {
return "CLI version is out of date and must be upgraded"
}

// IsUnauthorized checks if the error is an unauthorized error
func IsUnauthorized(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusUnauthorized
}
return false
}

// IsNotFound checks if the error is a not found error
func IsNotFound(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}

// IsBadRequest checks if the error is a bad request error
func IsBadRequest(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusBadRequest
}
return false
}

// IsNoToken checks if the error is a no token error
func IsNoToken(err error) bool {
var noTokenErr *NoTokenError
return errors.As(err, &noTokenErr)
}

// HasErrorCode checks if the error has a specific internal error code
func HasErrorCode(err error, code int) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.InternalCode == code
}
return false
}

// IsAuthorizationPending checks if the error is an authorization pending error
func IsAuthorizationPending(err error) bool {
return HasErrorCode(err, ErrorCodeAuthorizationPending)
}

// IsInvalidDeviceCode checks if the error is an invalid device code error
func IsInvalidDeviceCode(err error) bool {
return HasErrorCode(err, ErrorCodeInvalidDeviceCode)
}

// GetErrorCode returns the internal error code from an error, or 0 if not an APIError
func GetErrorCode(err error) int {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.InternalCode
}
return 0
}

// IsForceUpgrade checks if the error is a force upgrade error
func IsForceUpgrade(err error) bool {
var forceUpgradeErr *ForceUpgradeError
return errors.As(err, &forceUpgradeErr)
}

// IsTokenExpired checks if the error is a token expiration error
func IsTokenExpired(err error) bool {
return HasErrorCode(err, ErrorCodeInvalidToken)
}

// CheckErr checks for errors and prints appropriate messages using the command's output
// Returns true if no error (ok to continue), false if there was an error
func CheckErr(cmd *cobra.Command, err error) bool {
if err == nil {
return true
}
// errorCodeToCLIError maps API error codes to CLIError instances
var errorCodeToCLIError = map[int]*clierrors.CLIError{
// Authentication & Authorization Errors (2000-2099)
ErrorCodeUnauthorized: clierrors.ErrorUnauthorized,
ErrorCodeInvalidToken: clierrors.ErrorInvalidToken,
ErrorCodeInvalidUserCode: clierrors.ErrorInvalidUserCode,
ErrorCodeTokenNotFound: clierrors.ErrorTokenNotFound,
ErrorCodeInvalidDeviceCode: clierrors.ErrorInvalidDeviceCode,
ErrorCodeAuthorizationPending: clierrors.ErrorAuthorizationPending,

// Check if it's a force upgrade error
if IsForceUpgrade(err) {
ui.PrintError(cmd, "Your CLI version is out of date and must be upgraded.", "brew update && brew upgrade major")
return false
}
// Organization Errors (3000-3099)
ErrorCodeOrganizationNotFound: clierrors.ErrorOrganizationNotFoundAPI,
ErrorCodeNotOrgMember: clierrors.ErrorNotOrgMember,
ErrorCodeNoCreatePermission: clierrors.ErrorNoCreatePermission,

// Check if it's a token expiration error
if IsTokenExpired(err) {
ui.PrintError(cmd, "Your session has expired!", "major user login")
return false
}
// Application Errors (4000-4099)
ErrorCodeApplicationNotFound: clierrors.ErrorApplicationNotFoundAPI,
ErrorCodeNoApplicationAccess: clierrors.ErrorNoApplicationAccess,
ErrorCodeDuplicateAppName: clierrors.ErrorDuplicateAppName,

// Check if it's a no token error
if IsNoToken(err) {
ui.PrintError(cmd, "Not logged in!", "major user login")
return false
// GitHub Integration Errors (5000-5099)
ErrorCodeGitHubRepoNotFound: clierrors.ErrorGitHubRepoNotFound,
ErrorCodeGitHubRepoAccessDenied: clierrors.ErrorGitHubRepoAccessDenied,
ErrorCodeGitHubCollaboratorAddFailed: clierrors.ErrorGitHubCollaboratorAddFailed,
}

// ToCLIError converts an APIError to a CLIError
// If a specific error code mapping exists, it returns that CLIError
// Otherwise, it creates a generic CLIError with the API error details
func ToCLIError(errResp *ErrorResponse) error {
// Check if we have a specific mapping for this error code
if cliErr, exists := errorCodeToCLIError[errResp.Error.InternalCode]; exists {
return cliErr
}

// Check if it's an API error
var apiErr *APIError
if errors.As(err, &apiErr) {
// Just print the error description/message, nothing else
cmd.Printf("Error: %s\n", apiErr.Message)
return false
// No specific mapping - create a generic CLIError with API details
return &clierrors.CLIError{
Title: fmt.Sprintf("API Error (Code: %d)", errResp.Error.InternalCode),
Suggestion: "Please try again or contact support if the issue persists.",
Err: fmt.Errorf("%s", errResp.Error.ErrorString),
}

// Generic error
cmd.Printf("Error: %v\n", err)
return false
}
12 changes: 8 additions & 4 deletions clients/git/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"os/exec"
"regexp"
"strings"

"github.com/pkg/errors"

clierrors "github.com/major-technology/cli/errors"
)

// RemoteInfo contains parsed information from a git remote URL
Expand All @@ -32,7 +36,7 @@ func GetRemoteURLFromDir(dir string) (string, error) {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
if strings.Contains(stderr, "not a git repository") {
return "", fmt.Errorf("you currently are not in a git repo")
return "", clierrors.ErrorNotGitRepository
}
}
return "", err
Expand All @@ -47,7 +51,7 @@ func Clone(url, targetDir string) error {
output, err := cmd.CombinedOutput()
if err != nil {
// Include the git output in the error message
return fmt.Errorf("%w: %s", err, string(output))
return errors.Wrap(err, "git clone failed: "+string(output))
}
// Print output on success
fmt.Print(string(output))
Expand Down Expand Up @@ -107,7 +111,7 @@ func ParseRemoteURL(remoteURL string) (*RemoteInfo, error) {
}, nil
}

return nil, fmt.Errorf("unsupported remote URL format: %s", remoteURL)
return nil, clierrors.ErrorUnsupportedGitRemoteURLWithFormat(remoteURL)
}

// GetRepoRoot returns the root directory of the git repository
Expand Down Expand Up @@ -173,7 +177,7 @@ func Pull(repoDir string) error {
output, err := cmd.CombinedOutput()
if err != nil {
// Include the git output in the error message
return fmt.Errorf("%w: %s", err, string(output))
return errors.Wrap(err, "git pull failed: "+string(output))
}
return nil
}
Loading