From 0c85aaf6963917262047570c5741b0eab9765f9c Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Fri, 18 Sep 2026 12:50:28 +0530 Subject: [PATCH] feat: classify local command failures and additional API statuses in the JSON error envelope Wrap local, user-fixable command validation failures (missing required flags, incompatible flag combinations, invalid flag values, malformed local JSON payloads, missing input) so they surface with a specific code and reason in the JSON error envelope instead of the generic unknown/unclassified. Human-facing messages are unchanged. Also classify 409 API responses as conflict, and 410/415 as validation, instead of falling back to unknown/server_error. --- CHANGELOG.md | 2 ++ internal/cli/actions.go | 12 ++++---- internal/cli/actions_modules.go | 2 +- internal/cli/acul_app_scaffolding.go | 6 ++-- internal/cli/acul_config.go | 4 +-- internal/cli/acul_dev.go | 4 +-- internal/cli/api.go | 40 +++++++++++++++------------ internal/cli/apps.go | 6 ++-- internal/cli/arguments.go | 6 ++-- internal/cli/client_grants.go | 10 +++---- internal/cli/custom_domains.go | 2 +- internal/cli/data_json.go | 2 +- internal/cli/email_provider.go | 12 ++++---- internal/cli/event_streams.go | 2 +- internal/cli/exit.go | 10 ++++++- internal/cli/exit_test.go | 6 ++++ internal/cli/flags.go | 2 +- internal/cli/flows.go | 2 +- internal/cli/flows_vault.go | 6 ++-- internal/cli/forms.go | 12 ++++---- internal/cli/guardian_enrollments.go | 2 +- internal/cli/guardian_factor_phone.go | 8 +++--- internal/cli/guardian_factor_push.go | 4 +-- internal/cli/guardian_factor_sms.go | 4 +-- internal/cli/guardian_factors.go | 2 +- internal/cli/guardian_policies.go | 4 +-- internal/cli/login.go | 4 +-- internal/cli/network_acl.go | 16 +++++------ internal/cli/quickstarts.go | 20 +++++++------- internal/cli/refresh_tokens.go | 2 +- internal/cli/roles_permissions.go | 2 +- internal/cli/root.go | 2 +- internal/cli/tenant_settings.go | 2 +- internal/cli/terraform.go | 2 +- internal/cli/users.go | 6 ++-- internal/cli/users_roles.go | 6 ++-- internal/cli/utils_shared.go | 8 +++--- 37 files changed, 132 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95290a8d9..b178224ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--data` input is now checked for well-formed JSON even when the operation has no local schema, so malformed payloads fail locally with a clear message instead of being sent to the API as-is. - Encode `--query` filters with real JSON-to-query semantics. An array value now sends repeated parameters (`?fields=a&fields=b`) instead of the literal `[a b]`, numbers keep their original literal (no more `1e+06` for `1000000`), a JSON `null` omits the parameter instead of sending an empty value, and a nested object is rejected with a clear error rather than silently building a wrong request. A malformed or nested `--query` now classifies as a `validation` error in the JSON error envelope instead of `unknown`. - Send every value of a repeated `-q`/`--query` param on `auth0 api` (for example `-q "fields=a" -q "fields=b"`), instead of keeping only the last one +- Classify local, user-fixable command failures with a specific `code` and `reason` in the JSON error envelope instead of the generic `unknown`/`unclassified`. Missing required flags, incompatible flag combinations, and invalid flag values (across `auth0 client-grants`, `auth0 apps`, `auth0 quickstarts`, `auth0 network-acl`, `auth0 guardian`, `auth0 email`, `auth0 actions`, `auth0 roles`, `auth0 users`, `auth0 flows`, `auth0 forms`, `auth0 login`, `auth0 terraform generate`, and others) now surface as `usage`; malformed local JSON payloads and other client-side input validation surface as `validation`. Human-facing messages are unchanged +- Classify `409` API responses as `conflict` in the JSON error envelope, and `410`/`415` as `validation`, instead of the generic `unknown`/`server_error` # [v1.35.0](https://github.com/auth0/auth0-cli/tree/v1.35.0) (September 10, 2026) diff --git a/internal/cli/actions.go b/internal/cli/actions.go index 7321f7036..1b6592958 100644 --- a/internal/cli/actions.go +++ b/internal/cli/actions.go @@ -793,7 +793,7 @@ func diffActionCmd(cli *cli) *cobra.Command { // Exactly one version was supplied. Require both or neither so an // explicitly pinned version is never silently discarded (in // interactive mode the picker would otherwise overwrite it). - return fmt.Errorf("provide both --version1 and --version2, or neither") + return usageError{err: fmt.Errorf("provide both --version1 and --version2, or neither"), reason: "incompatible_flags"} case canPrompt(cmd): var err error inputs.version1, inputs.version2, err = pickTwoVersions(allVersions) @@ -801,7 +801,7 @@ func diffActionCmd(cli *cli) *cobra.Command { return err } default: - return fmt.Errorf("missing required flags in non-interactive mode: --version1 and --version2") + return usageError{err: fmt.Errorf("missing required flags in non-interactive mode: --version1 and --version2"), reason: "missing_required_flags"} } var code1, code2 string @@ -967,7 +967,7 @@ func inputModulesToActionModules(modules []string) (*[]management.ActionModules, key = strings.TrimSpace(key) value = strings.TrimSpace(value) if !found || key == "" || value == "" { - return nil, fmt.Errorf("invalid --module value %q: expected comma-separated key=value pairs (e.g. \"module_id=,module_version_id=\")", raw) + return nil, usageError{err: fmt.Errorf("invalid --module value %q: expected comma-separated key=value pairs (e.g. \"module_id=,module_version_id=\")", raw), reason: "invalid_flag_value"} } switch key { @@ -976,15 +976,15 @@ func inputModulesToActionModules(modules []string) (*[]management.ActionModules, case "module_version_id": module.ModuleVersionID = auth0.String(value) default: - return nil, fmt.Errorf("invalid --module value %q: unknown key %q (supported keys: module_id, module_version_id)", raw, key) + return nil, usageError{err: fmt.Errorf("invalid --module value %q: unknown key %q (supported keys: module_id, module_version_id)", raw, key), reason: "invalid_flag_value"} } } if module.ModuleID == nil { - return nil, fmt.Errorf("invalid --module value %q: module_id is required", raw) + return nil, usageError{err: fmt.Errorf("invalid --module value %q: module_id is required", raw), reason: "invalid_flag_value"} } if module.ModuleVersionID == nil { - return nil, fmt.Errorf("invalid --module value %q: module_version_id is required (the UUID of a specific module version)", raw) + return nil, usageError{err: fmt.Errorf("invalid --module value %q: module_version_id is required (the UUID of a specific module version)", raw), reason: "invalid_flag_value"} } actionModules = append(actionModules, module) diff --git a/internal/cli/actions_modules.go b/internal/cli/actions_modules.go index fb67c69af..74bf189a8 100644 --- a/internal/cli/actions_modules.go +++ b/internal/cli/actions_modules.go @@ -208,7 +208,7 @@ func createActionModuleCmd(cli *cli) *cobra.Command { } if !actionModuleNamePattern.MatchString(inputs.Name) { - return fmt.Errorf("invalid name %q: must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores, and hyphens", inputs.Name) + return validationError{err: fmt.Errorf("invalid name %q: must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores, and hyphens", inputs.Name), reason: "invalid_flag_value"} } if err := actionModuleCode.OpenEditor( diff --git a/internal/cli/acul_app_scaffolding.go b/internal/cli/acul_app_scaffolding.go index f33eec6d9..4d2cf79be 100644 --- a/internal/cli/acul_app_scaffolding.go +++ b/internal/cli/acul_app_scaffolding.go @@ -268,8 +268,8 @@ func selectTemplate(cmd *cobra.Command, manifest *Manifest, providedTemplate str return key, nil } } - return "", fmt.Errorf("invalid template '%s'. Available templates: %s", - providedTemplate, strings.Join(templateNames, ", ")) + return "", usageError{err: fmt.Errorf("invalid template '%s'. Available templates: %s", + providedTemplate, strings.Join(templateNames, ", ")), reason: "invalid_flag_value"} } var chosenTemplateName string @@ -326,7 +326,7 @@ func validateAndSelectScreens(cli *cli, screenIDs, providedScreens []string, mul return nil, err } if len(selected) == 0 { - return nil, fmt.Errorf("at least one screen must be selected") + return nil, usageError{err: fmt.Errorf("at least one screen must be selected"), reason: "missing_required_flags"} } return selected, nil } diff --git a/internal/cli/acul_config.go b/internal/cli/acul_config.go index 6fe9b5670..c77e2af52 100644 --- a/internal/cli/acul_config.go +++ b/internal/cli/acul_config.go @@ -437,7 +437,7 @@ func fetchRenderSettings(cmd *cobra.Command, cli *cli, input aculConfigInput) (* return nil, nil, false, fmt.Errorf("unable to read file %q: %v", input.filePath, err) } if err := json.Unmarshal(data, &renderSettings); err != nil { - return nil, nil, false, fmt.Errorf("file %q contains invalid JSON: %v", input.filePath, err) + return nil, nil, false, validationError{err: fmt.Errorf("file %q contains invalid JSON: %v", input.filePath, err), reason: "malformed_json"} } clearValue, shouldClear := detectHeadTagsClear(data) return renderSettings, clearValue, shouldClear, nil @@ -452,7 +452,7 @@ func fetchRenderSettings(cmd *cobra.Command, cli *cli, input aculConfigInput) (* message := fmt.Sprintf("Use file '%s' for updating remote ACUL configs for '%s'? : ", ansi.Green(defaultFilePath), ansi.Blue(input.screenName)) if confirmed := prompt.Confirm(message); confirmed { if err := json.Unmarshal(data, &renderSettings); err != nil { - return nil, nil, false, fmt.Errorf("file %s contains invalid JSON: %v", defaultFilePath, err) + return nil, nil, false, validationError{err: fmt.Errorf("file %s contains invalid JSON: %v", defaultFilePath, err), reason: "malformed_json"} } clearValue, shouldClear := detectHeadTagsClear(data) return renderSettings, clearValue, shouldClear, nil diff --git a/internal/cli/acul_dev.go b/internal/cli/acul_dev.go index cd8689ecd..c7f7ba02c 100644 --- a/internal/cli/acul_dev.go +++ b/internal/cli/acul_dev.go @@ -294,7 +294,7 @@ func runConnectedMode(ctx context.Context, cli *cli, projectDir, port string, sc } if _, err = strconv.Atoi(portInput); err != nil { - return fmt.Errorf("invalid port number: %s", portInput) + return usageError{err: fmt.Errorf("invalid port number: %s", portInput), reason: "invalid_flag_value"} } port = portInput @@ -409,7 +409,7 @@ func runConnectedMode(ctx context.Context, cli *cli, projectDir, port string, sc func validateAculProject(projectDir string) error { packagePath := filepath.Join(projectDir, "package.json") if _, err := os.Stat(packagePath); os.IsNotExist(err) { - return fmt.Errorf("package.json not found. This doesn't appear to be a valid ACUL project") + return validationError{err: fmt.Errorf("package.json not found. This doesn't appear to be a valid ACUL project"), reason: "invalid_project_dir"} } return nil } diff --git a/internal/cli/api.go b/internal/cli/api.go index 4db3f323f..47e6678d2 100644 --- a/internal/cli/api.go +++ b/internal/cli/api.go @@ -253,11 +253,14 @@ func (i *apiCmdInputs) validateAndSetMethod() error { } } - return fmt.Errorf( - "invalid method given: %s, accepting only %s", - i.RawMethod, - strings.Join(apiValidMethods, ", "), - ) + return usageError{ + err: fmt.Errorf( + "invalid method given: %s, accepting only %s", + i.RawMethod, + strings.Join(apiValidMethods, ", "), + ), + reason: "invalid_flag_value", + } } func (i *apiCmdInputs) validateAndSetData() error { @@ -272,7 +275,7 @@ func (i *apiCmdInputs) validateAndSetData() error { if len(data) > 0 { if err := json.Unmarshal(data, &i.Data); err != nil { - return fmt.Errorf("invalid JSON data provided: %w", err) + return validationError{err: fmt.Errorf("invalid JSON data provided: %w", err), reason: "malformed_json"} } } @@ -293,14 +296,14 @@ func (i *apiCmdInputs) resolveData() ([]byte, error) { return nil, err } if len(data) == 0 { - return nil, fmt.Errorf("no data received on stdin") + return nil, validationError{err: fmt.Errorf("no data received on stdin"), reason: "missing_input"} } return data, nil case strings.HasPrefix(i.RawData, "@"): path := i.RawData[1:] data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read data file %q: %w", path, err) + return nil, usageError{err: fmt.Errorf("failed to read data file %q: %w", path, err), reason: "invalid_flag_value"} } return data, nil default: @@ -314,7 +317,7 @@ func (i *apiCmdInputs) resolveData() ([]byte, error) { func (i *apiCmdInputs) validateAndSetEndpoint(domain string) error { endpoint, err := url.Parse(fmt.Sprintf("https://%s/api/v2/%s", domain, strings.Trim(i.RawURI, "/"))) if err != nil { - return fmt.Errorf("invalid uri given: %w", err) + return usageError{err: fmt.Errorf("invalid uri given: %w", err), reason: "invalid_flag_value"} } params := endpoint.Query() @@ -325,7 +328,7 @@ func (i *apiCmdInputs) validateAndSetEndpoint(domain string) error { for _, pair := range strings.Split(raw, ",") { key, value, found := strings.Cut(pair, "=") if !found { - return fmt.Errorf("invalid query parameter %q: expected key=value", pair) + return usageError{err: fmt.Errorf("invalid query parameter %q: expected key=value", pair), reason: "invalid_flag_value"} } // Add (not Set) so a repeated key sends every value instead of the last // one overwriting the rest. @@ -401,11 +404,14 @@ func isInsufficientScopeError(statusCode int, rawBody []byte) error { } } - return fmt.Errorf( - "request failed because access token lacks scope: %s.\n "+ - "If authenticated via client credentials, add this scope to the designated client. "+ - "If authenticated as a user, request this scope during login by running `auth0 login --scopes %s`", - recommendedScopeToAdd, - recommendedScopeToAdd, - ) + return authError{ + err: fmt.Errorf( + "request failed because access token lacks scope: %s.\n "+ + "If authenticated via client credentials, add this scope to the designated client. "+ + "If authenticated as a user, request this scope during login by running `auth0 login --scopes %s`", + recommendedScopeToAdd, + recommendedScopeToAdd, + ), + reason: "insufficient_scope", + } } diff --git a/internal/cli/apps.go b/internal/cli/apps.go index f9e43dd5e..bac392890 100644 --- a/internal/cli/apps.go +++ b/internal/cli/apps.go @@ -631,7 +631,7 @@ func createAppCmd(cli *cli) *cobra.Command { inputs.ResourceServerIdentifier = selectedAPI.GetIdentifier() } else if strings.TrimSpace(inputs.ResourceServerIdentifier) == "" { - return fmt.Errorf("resource-server-identifier cannot be empty for resource_server app type") + return usageError{err: fmt.Errorf("resource-server-identifier cannot be empty for resource_server app type"), reason: "missing_required_flags"} } } @@ -669,7 +669,7 @@ func createAppCmd(cli *cli) *cobra.Command { if len(inputs.RefreshToken) != 0 { if err := json.Unmarshal([]byte(inputs.RefreshToken), &a.RefreshToken); err != nil { - return fmt.Errorf("apps: %s refreshToken invalid JSON", err) + return validationError{err: fmt.Errorf("apps: %s refreshToken invalid JSON", err), reason: "malformed_json"} } } @@ -975,7 +975,7 @@ func updateAppCmd(cli *cli) *cobra.Command { a.RefreshToken = current.RefreshToken } else { if err := json.Unmarshal([]byte(inputs.RefreshToken), &a.RefreshToken); err != nil { - return fmt.Errorf("apps: %s refreshToken invalid JSON", err) + return validationError{err: fmt.Errorf("apps: %s refreshToken invalid JSON", err), reason: "malformed_json"} } } diff --git a/internal/cli/arguments.go b/internal/cli/arguments.go index 14eaf5fd5..185649f18 100644 --- a/internal/cli/arguments.go +++ b/internal/cli/arguments.go @@ -38,7 +38,7 @@ type pickerOptionsFunc func(ctx context.Context) (pickerOptions, error) func (a *Argument) Pick(cmd *cobra.Command, result *string, fn pickerOptionsFunc) error { if !canPrompt(cmd) { - return fmt.Errorf("missing a required argument: %s", a.GetName()) + return usageError{err: fmt.Errorf("missing a required argument: %s", a.GetName()), reason: "missing_required_args"} } var opts pickerOptions @@ -64,7 +64,7 @@ func (a *Argument) Pick(cmd *cobra.Command, result *string, fn pickerOptionsFunc func (a *Argument) PickMany(cmd *cobra.Command, result *[]string, fn pickerOptionsFunc) error { if !canPrompt(cmd) { - return fmt.Errorf("missing a required argument: %s", a.GetName()) + return usageError{err: fmt.Errorf("missing a required argument: %s", a.GetName()), reason: "missing_required_args"} } var opts pickerOptions @@ -92,5 +92,5 @@ func askArgument(cmd *cobra.Command, i commandInput, value interface{}) error { return ask(i, value, nil, false) } - return fmt.Errorf("missing a required argument: %s", i.GetName()) + return usageError{err: fmt.Errorf("missing a required argument: %s", i.GetName()), reason: "missing_required_args"} } diff --git a/internal/cli/client_grants.go b/internal/cli/client_grants.go index 9f9afd5ab..a6d9d499f 100644 --- a/internal/cli/client_grants.go +++ b/internal/cli/client_grants.go @@ -186,7 +186,7 @@ func listClientGrantsCmd(cli *cli) *cobra.Command { // The API requires a client_id, audience or default_for filter // alongside subject_type; catch it early with a clearer message. if inputs.SubjectType != "" && inputs.ClientID == "" && inputs.Audience == "" && inputs.DefaultFor == "" { - return fmt.Errorf("--subject-type must be combined with --client-id, --audience or --default-for") + return usageError{err: fmt.Errorf("--subject-type must be combined with --client-id, --audience or --default-for"), reason: "incompatible_flags"} } request := &managementv3.ListClientGrantsRequestParameters{} @@ -352,7 +352,7 @@ func createClientGrantCmd(cli *cli) *cobra.Command { } if inputs.ClientID == "" && inputs.DefaultFor == "" { - return errors.New("one of --client-id or --default-for must be set") + return usageError{err: errors.New("one of --client-id or --default-for must be set"), reason: "missing_required_flags"} } // A default grant is a template for a group of clients rather than an @@ -376,7 +376,7 @@ func createClientGrantCmd(cli *cli) *cobra.Command { if isDefaultGrant { for _, f := range []*Flag{&clientGrantSubjectType, &clientGrantOrganizationUsage, &clientGrantAllowAnyOrganization} { if f.IsSet(cmd) { - return fmt.Errorf("--%s cannot be set with --default-for", f.LongForm) + return usageError{err: fmt.Errorf("--%s cannot be set with --default-for", f.LongForm), reason: "incompatible_flags"} } } } @@ -892,7 +892,7 @@ func clientGrantSubjectTypeAllowsOrganizations(subjectType string) bool { // into a clear, actionable message for the non-interactive path. func validateClientGrantSubjectType(subjectType, organizationUsage string, allowAnyOrganization bool) error { if !clientGrantSubjectTypeAllowsOrganizations(subjectType) && (organizationUsage != "" || allowAnyOrganization) { - return fmt.Errorf("--organization-usage and --allow-any-organization cannot be set when --subject-type is %q", subjectType) + return usageError{err: fmt.Errorf("--organization-usage and --allow-any-organization cannot be set when --subject-type is %q", subjectType), reason: "incompatible_flags"} } return nil } @@ -909,7 +909,7 @@ func clientGrantOrganizationAllowsAny(organizationUsage string) bool { // 400 into a clear, actionable message. func validateClientGrantOrganization(organizationUsage string, allowAnyOrganization bool) error { if allowAnyOrganization && !clientGrantOrganizationAllowsAny(organizationUsage) { - return errors.New("--allow-any-organization can only be enabled when --organization-usage is 'allow' or 'require'") + return usageError{err: errors.New("--allow-any-organization can only be enabled when --organization-usage is 'allow' or 'require'"), reason: "incompatible_flags"} } return nil } diff --git a/internal/cli/custom_domains.go b/internal/cli/custom_domains.go index abb8492a0..33242f7c2 100644 --- a/internal/cli/custom_domains.go +++ b/internal/cli/custom_domains.go @@ -125,7 +125,7 @@ func listCustomDomainsCmd(cli *cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { // Validate EA-only flags. if inputs.sortBy != "" && inputs.sortBy != "domain" { - return fmt.Errorf("sorting is only supported by domain at this time") + return usageError{err: fmt.Errorf("sorting is only supported by domain at this time"), reason: "invalid_flag_value"} } var domains []*management.CustomDomain diff --git a/internal/cli/data_json.go b/internal/cli/data_json.go index a784503ee..5a177143a 100644 --- a/internal/cli/data_json.go +++ b/internal/cli/data_json.go @@ -101,7 +101,7 @@ func marshalFieldErrors(fieldErrors []openapi.FieldError) json.RawMessage { // readJSONInput reads JSON from various input sources. func (h *DataJSONHandler) readJSONInput(input string) ([]byte, error) { if input == "" { - return nil, fmt.Errorf("no input provided") + return nil, validationError{err: fmt.Errorf("no input provided"), reason: "missing_input"} } // "@-" and "-" read the payload from stdin, the common agent/script idiom for diff --git a/internal/cli/email_provider.go b/internal/cli/email_provider.go index aa4e24f56..1b4c34960 100644 --- a/internal/cli/email_provider.go +++ b/internal/cli/email_provider.go @@ -175,7 +175,7 @@ func createEmailProviderCmd(cli *cli) *cobra.Command { var credentials map[string]interface{} if inputs.name == emailProviderCustom { if len(inputs.credentials) > 0 { - return fmt.Errorf("credentials not supported for provider: %s", inputs.name) + return usageError{err: fmt.Errorf("credentials not supported for provider: %s", inputs.name), reason: "invalid_flag_value"} } credentials = make(map[string]interface{}) } else { @@ -205,10 +205,10 @@ func createEmailProviderCmd(cli *cli) *cobra.Command { emailProviderMS365, emailProviderCustom: if len(inputs.settings) > 0 { - return fmt.Errorf("settings not supported for provider: %s", inputs.name) + return usageError{err: fmt.Errorf("settings not supported for provider: %s", inputs.name), reason: "invalid_flag_value"} } default: - return fmt.Errorf("unknown provider: %s", inputs.name) + return usageError{err: fmt.Errorf("unknown provider: %s", inputs.name), reason: "invalid_flag_value"} } emailProvider := &management.EmailProvider{ @@ -319,7 +319,7 @@ func updateEmailProviderCmd(cli *cli) *cobra.Command { // If we are changing providers, we need new credentials and settings. if inputs.name == emailProviderCustom { if len(inputs.credentials) > 0 { - return fmt.Errorf("credentials not supported for provider: %s", inputs.name) + return usageError{err: fmt.Errorf("credentials not supported for provider: %s", inputs.name), reason: "invalid_flag_value"} } credentials = make(map[string]interface{}) } else { @@ -348,10 +348,10 @@ func updateEmailProviderCmd(cli *cli) *cobra.Command { emailProviderMS365, emailProviderCustom: if len(inputs.settings) > 0 { - return fmt.Errorf("settings not supported for provider: %s", inputs.name) + return usageError{err: fmt.Errorf("settings not supported for provider: %s", inputs.name), reason: "invalid_flag_value"} } default: - return fmt.Errorf("unknown provider: %s", inputs.name) + return usageError{err: fmt.Errorf("unknown provider: %s", inputs.name), reason: "invalid_flag_value"} } } diff --git a/internal/cli/event_streams.go b/internal/cli/event_streams.go index 9930858b0..a0ef184d9 100644 --- a/internal/cli/event_streams.go +++ b/internal/cli/event_streams.go @@ -216,7 +216,7 @@ func createEventStreamCmd(cli *cli) *cobra.Command { } if len(inputs.Configuration) == 0 { - return fmt.Errorf("must provider configuration for event stream") + return usageError{err: fmt.Errorf("must provider configuration for event stream"), reason: "missing_required_flags"} } var subscriptions []management.EventStreamSubscription diff --git a/internal/cli/exit.go b/internal/cli/exit.go index 0e52b3e64..052436f6b 100644 --- a/internal/cli/exit.go +++ b/internal/cli/exit.go @@ -307,10 +307,12 @@ func errorClassForHTTPStatus(status int) string { switch { case status == 401 || status == 403: return "auth" - case status == 400 || status == 422: + case status == 400 || status == 422 || status == 410 || status == 415: return "validation" case status == 404: return "not_found" + case status == 409: + return "conflict" case status == 429: return "rate_limit" case status >= 500: @@ -332,6 +334,12 @@ func reasonForHTTPStatus(status int) string { return "invalid_request" case 404: return "not_found" + case 409: + return "conflict" + case 410: + return "gone" + case 415: + return "unsupported_media_type" case 429: return "rate_limited" default: diff --git a/internal/cli/exit_test.go b/internal/cli/exit_test.go index 76a5a39ce..ee521cb60 100644 --- a/internal/cli/exit_test.go +++ b/internal/cli/exit_test.go @@ -47,6 +47,9 @@ func TestErrorClass(t *testing.T) { {name: "400 bad request", err: fakeManagementError{status: 400}, expected: "validation"}, {name: "422 unprocessable", err: fakeManagementError{status: 422}, expected: "validation"}, {name: "404 not found", err: fakeManagementError{status: 404}, expected: "not_found"}, + {name: "409 conflict", err: fakeManagementError{status: 409}, expected: "conflict"}, + {name: "410 gone", err: fakeManagementError{status: 410}, expected: "validation"}, + {name: "415 unsupported media type", err: fakeManagementError{status: 415}, expected: "validation"}, {name: "429 rate limited", err: fakeManagementError{status: 429}, expected: "rate_limit"}, {name: "500 server error", err: fakeManagementError{status: 500}, expected: "api"}, {name: "503 server error", err: fakeManagementError{status: 503}, expected: "api"}, @@ -103,6 +106,9 @@ func TestErrorReason(t *testing.T) { {name: "400 invalid request", err: fakeManagementError{status: 400}, expected: "invalid_request"}, {name: "422 invalid request", err: fakeManagementError{status: 422}, expected: "invalid_request"}, {name: "404 not found", err: fakeManagementError{status: 404}, expected: "not_found"}, + {name: "409 conflict", err: fakeManagementError{status: 409}, expected: "conflict"}, + {name: "410 gone", err: fakeManagementError{status: 410}, expected: "gone"}, + {name: "415 unsupported media type", err: fakeManagementError{status: 415}, expected: "unsupported_media_type"}, {name: "429 rate limited", err: fakeManagementError{status: 429}, expected: "rate_limited"}, {name: "500 server error", err: fakeManagementError{status: 500}, expected: "server_error"}, {name: "418 server error", err: fakeManagementError{status: 418}, expected: "server_error"}, diff --git a/internal/cli/flags.go b/internal/cli/flags.go index ca10593a2..56dc44834 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -433,7 +433,7 @@ func askIntSlice(i commandInput, value *[]int, defaultValue *[]int) error { } v := 0 if _, err := fmt.Sscanf(part, "%d", &v); err != nil { - return fmt.Errorf("invalid integer value: %s", part) + return usageError{err: fmt.Errorf("invalid integer value: %s", part), reason: "invalid_flag_value"} } result = append(result, v) } diff --git a/internal/cli/flows.go b/internal/cli/flows.go index fe726a9b8..2f0d5840e 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -218,7 +218,7 @@ func createFlowCmd(cli *cli) *cobra.Command { return err } if inputs.Name == "" { - return errors.New("a flow name is required; supply --name") + return usageError{err: errors.New("a flow name is required; supply --name"), reason: "missing_required_flags"} } var rawBody json.RawMessage diff --git a/internal/cli/flows_vault.go b/internal/cli/flows_vault.go index b5f696d9f..9c814dee4 100644 --- a/internal/cli/flows_vault.go +++ b/internal/cli/flows_vault.go @@ -230,7 +230,7 @@ func createVaultConnectionCmd(cli *cli) *cobra.Command { return err } if inputs.AppID == "" { - return errors.New("an app id is required") + return usageError{err: errors.New("an app id is required"), reason: "missing_required_flags"} } cli.renderer.Warnf("Setup schema for %s. Save to a file and pass with --setup-file.", inputs.AppID) cli.renderer.FlowExport(vaultConnectionSeedForApp(inputs.AppID)) @@ -241,13 +241,13 @@ func createVaultConnectionCmd(cli *cli) *cobra.Command { return err } if inputs.Name == "" { - return errors.New("a name is required") + return usageError{err: errors.New("a name is required"), reason: "missing_required_flags"} } if err := vaultConnectionAppID.Pick(cmd, &inputs.AppID, cli.vaultAppIDPickerOptions); err != nil { return err } if inputs.AppID == "" { - return errors.New("an app id is required") + return usageError{err: errors.New("an app id is required"), reason: "missing_required_flags"} } var setupBody json.RawMessage diff --git a/internal/cli/forms.go b/internal/cli/forms.go index d338c4d59..0dd76edb4 100644 --- a/internal/cli/forms.go +++ b/internal/cli/forms.go @@ -314,7 +314,7 @@ func createFormCmd(cli *cli) *cobra.Command { return err } if inputs.Name == "" { - return errors.New("a form name is required; supply --name, --data, or pipe JSON via stdin") + return usageError{err: errors.New("a form name is required; supply --name, --data, or pipe JSON via stdin"), reason: "missing_required_flags"} } rawBody := json.RawMessage(formCreateSkeleton) @@ -704,7 +704,7 @@ func importFormCmd(cli *cli) *cobra.Command { return err } if body == nil { - return errors.New("no form body provided; supply --data or pipe JSON via stdin") + return usageError{err: errors.New("no form body provided; supply --data or pipe JSON via stdin"), reason: "missing_required_flags"} } if isFormEnvelope(body) { @@ -727,7 +727,7 @@ func importFormCmd(cli *cli) *cobra.Command { if inputs.ID == "" { if meta.Name == "" { - return errors.New("a form name is required in the imported body") + return validationError{err: errors.New("a form name is required in the imported body"), reason: "invalid_body"} } raw, err := cli.formRawCreate(cmd.Context(), body) @@ -886,7 +886,7 @@ func validateFormData( } if name == "" { cli.renderer.Infof("Run '%s --schema' to see the accepted schema.", schemaCmd) - return nil, errors.New(`the form payload must include a non-empty "name"`) + return nil, validationError{err: errors.New(`the form payload must include a non-empty "name"`), reason: "invalid_body"} } } @@ -915,7 +915,7 @@ func applyRawFormOverrides(body json.RawMessage, name, primary, def string) (jso return nil, err } if form == nil { - return nil, errors.New("form body must be a JSON object") + return nil, validationError{err: errors.New("form body must be a JSON object"), reason: "malformed_json"} } if name != "" { @@ -963,7 +963,7 @@ func rawFormStringField(body json.RawMessage, field string) (string, error) { return "", err } if form == nil { - return "", errors.New("form body must be a JSON object") + return "", validationError{err: errors.New("form body must be a JSON object"), reason: "malformed_json"} } raw, ok := form[field] diff --git a/internal/cli/guardian_enrollments.go b/internal/cli/guardian_enrollments.go index f7926405b..ded17c3dc 100644 --- a/internal/cli/guardian_enrollments.go +++ b/internal/cli/guardian_enrollments.go @@ -87,7 +87,7 @@ func createGuardianEnrollmentTicketCmd(cli *cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if !guardianEnrollmentUserID.IsSet(cmd) { if !canPrompt(cmd) { - return fmt.Errorf("--user-id is required when running non-interactively") + return usageError{err: fmt.Errorf("--user-id is required when running non-interactively"), reason: "missing_required_flags"} } if err := guardianEnrollmentUserID.Ask(cmd, &inputs.UserID, nil); err != nil { return err diff --git a/internal/cli/guardian_factor_phone.go b/internal/cli/guardian_factor_phone.go index 2de7ec6e5..d209cf123 100644 --- a/internal/cli/guardian_factor_phone.go +++ b/internal/cli/guardian_factor_phone.go @@ -86,12 +86,12 @@ func setGuardianPhoneProviderCmd(cli *cli) *cobra.Command { } if provider == "" { - return fmt.Errorf("--provider is required: valid values are auth0, twilio, phone-message-hook") + return usageError{err: fmt.Errorf("--provider is required: valid values are auth0, twilio, phone-message-hook"), reason: "missing_required_flags"} } value, err := managementv3.NewGuardianFactorsProviderSmsProviderEnumFromString(provider) if err != nil { - return fmt.Errorf("invalid provider %q: valid values are auth0, twilio, phone-message-hook", provider) + return usageError{err: fmt.Errorf("invalid provider %q: valid values are auth0, twilio, phone-message-hook", provider), reason: "invalid_flag_value"} } body := &managementv3.SetGuardianFactorsProviderPhoneRequestContent{Provider: value} @@ -160,7 +160,7 @@ func setGuardianPhoneMessageTypesCmd(cli *cli) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if !guardianMessageType.IsSet(cmd) { if !canPrompt(cmd) { - return fmt.Errorf("--message-type is required when running non-interactively; supported values: sms, voice") + return usageError{err: fmt.Errorf("--message-type is required when running non-interactively; supported values: sms, voice"), reason: "missing_required_flags"} } if err := guardianMessageType.PickMany(cmd, &messageTypes, staticPickerOptions(guardianMessageTypeOptions)); err != nil { return err @@ -171,7 +171,7 @@ func setGuardianPhoneMessageTypesCmd(cli *cli) *cobra.Command { for _, t := range messageTypes { value, err := managementv3.NewGuardianFactorPhoneFactorMessageTypeEnumFromString(t) if err != nil { - return fmt.Errorf("invalid message type %q: valid values are sms, voice", t) + return usageError{err: fmt.Errorf("invalid message type %q: valid values are sms, voice", t), reason: "invalid_flag_value"} } types = append(types, value) } diff --git a/internal/cli/guardian_factor_push.go b/internal/cli/guardian_factor_push.go index 911afd5e0..785b189b8 100644 --- a/internal/cli/guardian_factor_push.go +++ b/internal/cli/guardian_factor_push.go @@ -79,12 +79,12 @@ func setGuardianPushProviderCmd(cli *cli) *cobra.Command { } if provider == "" { - return fmt.Errorf("--provider is required: valid values are guardian, sns, direct") + return usageError{err: fmt.Errorf("--provider is required: valid values are guardian, sns, direct"), reason: "missing_required_flags"} } value, err := managementv3.NewGuardianFactorsProviderPushNotificationProviderDataEnumFromString(provider) if err != nil { - return fmt.Errorf("invalid provider %q: valid values are guardian, sns, direct", provider) + return usageError{err: fmt.Errorf("invalid provider %q: valid values are guardian, sns, direct", provider), reason: "invalid_flag_value"} } body := &managementv3.SetGuardianFactorsProviderPushNotificationRequestContent{Provider: value} diff --git a/internal/cli/guardian_factor_sms.go b/internal/cli/guardian_factor_sms.go index 698815f33..fb0cce627 100644 --- a/internal/cli/guardian_factor_sms.go +++ b/internal/cli/guardian_factor_sms.go @@ -79,12 +79,12 @@ func setGuardianSmsProviderCmd(cli *cli) *cobra.Command { } if provider == "" { - return fmt.Errorf("--provider is required: valid values are auth0, twilio, phone-message-hook") + return usageError{err: fmt.Errorf("--provider is required: valid values are auth0, twilio, phone-message-hook"), reason: "missing_required_flags"} } value, err := managementv3.NewGuardianFactorsProviderSmsProviderEnumFromString(provider) if err != nil { - return fmt.Errorf("invalid provider %q: valid values are auth0, twilio, phone-message-hook", provider) + return usageError{err: fmt.Errorf("invalid provider %q: valid values are auth0, twilio, phone-message-hook", provider), reason: "invalid_flag_value"} } body := &managementv3.SetGuardianFactorsProviderSmsRequestContent{Provider: value} diff --git a/internal/cli/guardian_factors.go b/internal/cli/guardian_factors.go index 4a2713f9b..70eb26269 100644 --- a/internal/cli/guardian_factors.go +++ b/internal/cli/guardian_factors.go @@ -112,7 +112,7 @@ func setGuardianFactorCmd(cli *cli) *cobra.Command { if !guardianFactorEnabled.IsSet(cmd) { if !canPrompt(cmd) { - return fmt.Errorf("--enabled is required when running non-interactively (use --enabled or --enabled=false)") + return usageError{err: fmt.Errorf("--enabled is required when running non-interactively (use --enabled or --enabled=false)"), reason: "missing_required_flags"} } if err := guardianFactorEnabled.AskBool(cmd, &inputs.Enabled, nil); err != nil { return err diff --git a/internal/cli/guardian_policies.go b/internal/cli/guardian_policies.go index 8ed3f4cc4..7535f7ae7 100644 --- a/internal/cli/guardian_policies.go +++ b/internal/cli/guardian_policies.go @@ -102,7 +102,7 @@ func setGuardianPoliciesCmd(cli *cli) *cobra.Command { // Interactively pick a policy unless the user passed --policy or --none. if !guardianPolicies.IsSet(cmd) && !inputs.None { if !canPrompt(cmd) { - return fmt.Errorf("--policy or --none is required when running non-interactively; supported values: all-applications, confidence-score, none") + return usageError{err: fmt.Errorf("--policy or --none is required when running non-interactively; supported values: all-applications, confidence-score, none"), reason: "missing_required_flags"} } if err := guardianPolicies.Select(cmd, &inputs.Policy, guardianPolicyOptions, nil); err != nil { return err @@ -113,7 +113,7 @@ func setGuardianPoliciesCmd(cli *cli) *cobra.Command { if !inputs.None && inputs.Policy != "" && inputs.Policy != guardianPolicyNone { policy, err := managementv3.NewMfaPolicyEnumFromString(inputs.Policy) if err != nil { - return fmt.Errorf("invalid policy %q: valid values are all-applications, confidence-score, none", inputs.Policy) + return usageError{err: fmt.Errorf("invalid policy %q: valid values are all-applications, confidence-score, none", inputs.Policy), reason: "invalid_flag_value"} } body = append(body, policy) } diff --git a/internal/cli/login.go b/internal/cli/login.go index 1741ba57a..6e16c29a0 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -133,7 +133,7 @@ func loginCmd(cli *cli) *cobra.Command { inputs.ClientAssertionSigningAlg == "" && inputs.ClientAssertionPrivateKey == "": shouldLoginAsUser = true case inputs.Domain != "" || inputs.ClientID != "" || inputs.ClientSecret != "" || inputs.ClientAssertionSigningAlg != "" || inputs.ClientAssertionPrivateKey != "": - return fmt.Errorf("for machine login, provide domain with either (client-id, client-secret) or (client-id, client-assertion-signing-alg, client-assertion-private-key)") + return usageError{err: fmt.Errorf("for machine login, provide domain with either (client-id, client-secret) or (client-id, client-assertion-signing-alg, client-assertion-private-key)"), reason: "missing_required_flags"} default: /* If no flags are passed along with --no-input, it is defaulted to user login flow. @@ -260,7 +260,7 @@ func ensureAuth0URL(input string) (string, error) { // Check if the input ends with auth0.com . if !strings.HasSuffix(input, "auth0.com") { - return "", fmt.Errorf("not a valid auth0.com domain") + return "", validationError{err: fmt.Errorf("not a valid auth0.com domain"), reason: "invalid_flag_value"} } // Extract the domain part without any path. diff --git a/internal/cli/network_acl.go b/internal/cli/network_acl.go index f273a3dd2..d9d7bb5e5 100644 --- a/internal/cli/network_acl.go +++ b/internal/cli/network_acl.go @@ -66,10 +66,10 @@ type networkACLBasicInputs struct { // validateNetworkACLDescription ensures the description is non-empty and within the API length limit. func validateNetworkACLDescription(description string) error { if len(description) == 0 { - return fmt.Errorf("description cannot be empty") + return validationError{err: fmt.Errorf("description cannot be empty"), reason: "invalid_flag_value"} } if len(description) > 255 { - return fmt.Errorf("description cannot exceed 255 characters") + return validationError{err: fmt.Errorf("description cannot exceed 255 characters"), reason: "invalid_flag_value"} } return nil } @@ -86,7 +86,7 @@ func validateAndSetBasicFields(inputs *networkACLBasicInputs, patch *management. if networkACLActive.IsSet(cmd) { active, err := strconv.ParseBool(inputs.ActiveStr) if err != nil { - return fmt.Errorf("--active must be either 'true' or 'false', got %q", inputs.ActiveStr) + return usageError{err: fmt.Errorf("--active must be either 'true' or 'false', got %q", inputs.ActiveStr), reason: "invalid_flag_value"} } inputs.Active = active patch.Active = &inputs.Active @@ -143,7 +143,7 @@ func selectNetworkACLParams() (map[string]bool, error) { } if len(selected) == 0 { - return nil, errors.New("at least one parameter must be selected") + return nil, usageError{err: errors.New("at least one parameter must be selected"), reason: "missing_required_flags"} } // Convert selected slice to map for easier lookup. @@ -327,7 +327,7 @@ func promptForRuleDetails(cmd *cobra.Command, cli *cli, defaults *ruleDefaults, return nil, err } if inputs.RedirectURI == "" { - return nil, fmt.Errorf("redirect URI is required when action is redirect") + return nil, usageError{err: fmt.Errorf("redirect URI is required when action is redirect"), reason: "missing_required_flags"} } } @@ -617,7 +617,7 @@ func buildNetworkACLRule(inputs *ruleInputs) (*management.NetworkACLRule, error) } if !matchProvided { - return nil, fmt.Errorf("at least one match criteria must be provided") + return nil, usageError{err: fmt.Errorf("at least one match criteria must be provided"), reason: "missing_required_flags"} } // Set match or notmatch based on user choice. @@ -760,7 +760,7 @@ The --rule parameter is required and must contain a valid JSON object with actio if networkACLActive.IsSet(cmd) { active, err := strconv.ParseBool(inputs.ActiveStr) if err != nil { - return fmt.Errorf("--active must be either 'true' or 'false', got %q", inputs.ActiveStr) + return usageError{err: fmt.Errorf("--active must be either 'true' or 'false', got %q", inputs.ActiveStr), reason: "invalid_flag_value"} } inputs.Active = active } @@ -857,7 +857,7 @@ To update non-interactively, supply the description, active, priority, and rule networkACLPriority.IsSet(cmd) || networkACLRule.IsSet(cmd) if !canPrompt(cmd) && !flagsProvided { - return fmt.Errorf("in non-interactive mode, at least one field must be specified to update") + return usageError{err: fmt.Errorf("in non-interactive mode, at least one field must be specified to update"), reason: "missing_required_flags"} } // Build patch object with only the fields that should be updated. diff --git a/internal/cli/quickstarts.go b/internal/cli/quickstarts.go index e7bff2894..0cd4998a4 100644 --- a/internal/cli/quickstarts.go +++ b/internal/cli/quickstarts.go @@ -652,7 +652,7 @@ func runSetupQuickstart(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error return fmt.Errorf("failed to enter port: %w", err) } if inputs.Port < 1024 || inputs.Port > 65535 { - return fmt.Errorf("invalid port number: %d (must be between 1024 and 65535)", inputs.Port) + return usageError{err: fmt.Errorf("invalid port number: %d (must be between 1024 and 65535)", inputs.Port), reason: "invalid_flag_value"} } } @@ -689,7 +689,7 @@ func resolveSetupTargets(inputs *SetupInputs, canPromptFlag bool) error { return nil } if !canPromptFlag { - return fmt.Errorf("in --no-input mode, specify at least one of --app or --api") + return usageError{err: fmt.Errorf("in --no-input mode, specify at least one of --app or --api"), reason: "missing_required_flags"} } const ( @@ -745,7 +745,7 @@ func resolveAPIAppLink( } if !canPromptFlag { - return fmt.Errorf("in --no-input mode with --api, specify --app to create a new app or --linked-app-id to link an existing one") + return usageError{err: fmt.Errorf("in --no-input mode with --api, specify --app to create a new app or --linked-app-id to link an existing one"), reason: "missing_required_flags"} } const ( @@ -787,7 +787,7 @@ func resolveAPIAppLink( func collectName(cmd *cobra.Command, inputs *SetupInputs) error { if setupName.IsSet(cmd) { if inputs.Name == "" { - return fmt.Errorf("application name cannot be empty") + return usageError{err: fmt.Errorf("application name cannot be empty"), reason: "invalid_flag_value"} } return nil } @@ -803,7 +803,7 @@ func collectName(cmd *cobra.Command, inputs *SetupInputs) error { return fmt.Errorf("failed to enter application name: %w", err) } if inputs.Name == "" { - return fmt.Errorf("application name cannot be empty") + return usageError{err: fmt.Errorf("application name cannot be empty"), reason: "invalid_flag_value"} } case inputs.API && inputs.Name == "": @@ -834,7 +834,7 @@ func collectAPIInputs(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error { } } if inputs.Identifier == "" { - return fmt.Errorf("API identifier cannot be empty: use --identifier flag") + return usageError{err: fmt.Errorf("API identifier cannot be empty: use --identifier flag"), reason: "missing_required_flags"} } if err := validateAPIIdentifier(inputs.Identifier); err != nil { return err @@ -868,7 +868,7 @@ func collectAPIInputs(cmd *cobra.Command, cli *cli, inputs *SetupInputs) error { } } if alg := inputs.SigningAlg; alg != "RS256" && alg != "PS256" && alg != "HS256" { - return fmt.Errorf("invalid signing algorithm %q: must be RS256, PS256, or HS256", alg) + return usageError{err: fmt.Errorf("invalid signing algorithm %q: must be RS256, PS256, or HS256", alg), reason: "invalid_flag_value"} } return nil @@ -1052,7 +1052,7 @@ func printAPIDetails(cli *cli, rs *management.ResourceServer) { func createQuickstartApp(cmd *cobra.Command, cli *cli, inputs SetupInputs, qsConfigKey string) (string, error) { config, exists := auth0.QuickstartConfigs[qsConfigKey] if !exists { - return "", fmt.Errorf("unsupported quickstart arguments: %s. Supported types: %v", qsConfigKey, getSupportedQuickstartTypes()) + return "", usageError{err: fmt.Errorf("unsupported quickstart arguments: %s. Supported types: %v", qsConfigKey, getSupportedQuickstartTypes()), reason: "invalid_flag_value"} } expoScheme := readExpoScheme(inputs.Framework) @@ -1417,10 +1417,10 @@ func defaultPortForFramework(framework string) int { func validateAPIIdentifier(identifier string) error { u, err := url.ParseRequestURI(identifier) if err != nil { - return fmt.Errorf("invalid API identifier %q: must be a valid URI (e.g. https://my-api)", identifier) + return validationError{err: fmt.Errorf("invalid API identifier %q: must be a valid URI (e.g. https://my-api)", identifier), reason: "invalid_flag_value"} } if u.Scheme == "" || u.Host == "" { - return fmt.Errorf("invalid API identifier %q: must include a scheme and host (e.g. https://my-api)", identifier) + return validationError{err: fmt.Errorf("invalid API identifier %q: must include a scheme and host (e.g. https://my-api)", identifier), reason: "invalid_flag_value"} } return nil } diff --git a/internal/cli/refresh_tokens.go b/internal/cli/refresh_tokens.go index 4f02075a9..a07b678ec 100644 --- a/internal/cli/refresh_tokens.go +++ b/internal/cli/refresh_tokens.go @@ -234,7 +234,7 @@ func revokeRefreshTokenCmd(cli *cli) *cobra.Command { switch { case inputs.UserID != "": if len(args) > 0 { - return fmt.Errorf("pass either a token id or --user-id, not both") + return usageError{err: fmt.Errorf("pass either a token id or --user-id, not both"), reason: "incompatible_flags"} } body.UserID = &inputs.UserID target = fmt.Sprintf("all refresh tokens for user %q", inputs.UserID) diff --git a/internal/cli/roles_permissions.go b/internal/cli/roles_permissions.go index 1c6e4bb1a..b8ffa9ce9 100644 --- a/internal/cli/roles_permissions.go +++ b/internal/cli/roles_permissions.go @@ -273,7 +273,7 @@ func (c *cli) apiPickerOptionsWithoutAuth0(ctx context.Context) (pickerOptions, func (c *cli) pickRolePermissions(cmd *cobra.Command, apiScopes []management.ResourceServerScope, permissions *[]string) error { if !canPrompt(cmd) { - return fmt.Errorf("missing a required flag in non-interactive mode: --permissions") + return usageError{err: fmt.Errorf("missing a required flag in non-interactive mode: --permissions"), reason: "missing_required_flags"} } // NOTE(cyx): We're inlining this for now since we have no generic diff --git a/internal/cli/root.go b/internal/cli/root.go index b11f2e777..79c19dd31 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -64,7 +64,7 @@ In agent mode the CLI: • Disables interactive prompts and colors. • Exits 0 on success and 130 when interrupted; every other failure exits 1, so scripts that treat any non-zero exit as failure keep working. The specific - failure class (usage, auth, validation, not_found, rate_limit, api) is carried + failure class (usage, auth, validation, not_found, conflict, rate_limit, api) is carried by the JSON error envelope's "code" field, not by the exit code.` const panicMessage = ` diff --git a/internal/cli/tenant_settings.go b/internal/cli/tenant_settings.go index 504ca4b0a..026962b70 100644 --- a/internal/cli/tenant_settings.go +++ b/internal/cli/tenant_settings.go @@ -161,7 +161,7 @@ auth0 tenant-settings update unset flags.enable_pipeline2 --json`, func selectTenantSettingsParams(cmd *cobra.Command, isSet bool) ([]string, error) { if !canPrompt(cmd) { - return nil, fmt.Errorf("missing required arguments in non-interactive mode: pass the setting flags to change as arguments") + return nil, usageError{err: fmt.Errorf("missing required arguments in non-interactive mode: pass the setting flags to change as arguments"), reason: "missing_required_flags"} } var selectedFlags []string diff --git a/internal/cli/terraform.go b/internal/cli/terraform.go index ee87a2244..60287bec6 100644 --- a/internal/cli/terraform.go +++ b/internal/cli/terraform.go @@ -418,7 +418,7 @@ func checkTerraformProviderAndCLIDomainsMatch(currentCLIDomain string) error { if providerDomain == currentCLIDomain { return nil } - return fmt.Errorf("terraform provider tenant domain %q does not match current CLI tenant %q", providerDomain, currentCLIDomain) + return usageError{err: fmt.Errorf("terraform provider tenant domain %q does not match current CLI tenant %q", providerDomain, currentCLIDomain), reason: "tenant_domain_mismatch"} } func deduplicateResourceNames(data importDataList) importDataList { diff --git a/internal/cli/users.go b/internal/cli/users.go index 5036264f4..9960b500d 100644 --- a/internal/cli/users.go +++ b/internal/cli/users.go @@ -593,15 +593,15 @@ func validateRequiredFlags(inputs *userInput) error { switch inputs.connectionName { case "email": if inputs.email == "" { - return fmt.Errorf("required flag email not set") + return usageError{err: fmt.Errorf("required flag email not set"), reason: "missing_required_flags"} } case "sms": if inputs.phoneNumber == "" { - return fmt.Errorf("required flag phone-number not set") + return usageError{err: fmt.Errorf("required flag phone-number not set"), reason: "missing_required_flags"} } default: if inputs.email == "" || inputs.password == "" { - return fmt.Errorf("required flag email or password not set") + return usageError{err: fmt.Errorf("required flag email or password not set"), reason: "missing_required_flags"} } } diff --git a/internal/cli/users_roles.go b/internal/cli/users_roles.go index a164e30c9..4bf9148b3 100644 --- a/internal/cli/users_roles.go +++ b/internal/cli/users_roles.go @@ -152,7 +152,7 @@ func addUserRolesCmd(cli *cli) *cobra.Command { if len(inputs.Roles) == 0 { if !canPrompt(cmd) { - return fmt.Errorf("missing a required flag in non-interactive mode: --roles") + return usageError{err: fmt.Errorf("missing a required flag in non-interactive mode: --roles"), reason: "missing_required_flags"} } if err := cli.getUserRoles(cmd.Context(), &inputs, userRolesToAddPickerOptions, pickUserRoles); err != nil { return err @@ -217,7 +217,7 @@ func removeUserRolesCmd(cli *cli) *cobra.Command { if len(inputs.Roles) == 0 { if !canPrompt(cmd) { - return fmt.Errorf("missing a required flag in non-interactive mode: --roles") + return usageError{err: fmt.Errorf("missing a required flag in non-interactive mode: --roles"), reason: "missing_required_flags"} } if err := cli.getUserRoles(cmd.Context(), &inputs, userRolesToRemovePickerOptions, pickUserRoles); err != nil { return err @@ -278,7 +278,7 @@ func (c *cli) getUserRoles(ctx context.Context, inputs *userRolesInput, fetchUse } if len(inputs.Roles) == 0 { - return errNoRolesSelected + return usageError{err: errNoRolesSelected, reason: "missing_required_flags"} } return err diff --git a/internal/cli/utils_shared.go b/internal/cli/utils_shared.go index f3a3d53c4..5b709c6a5 100644 --- a/internal/cli/utils_shared.go +++ b/internal/cli/utils_shared.go @@ -420,7 +420,7 @@ func parseFlexibleDate(input string) (string, error) { } } - return "", fmt.Errorf("invalid date format: use RFC3339, 'YYYY-MM-DD', or formats like 'yesterday', '-2d'") + return "", usageError{err: fmt.Errorf("invalid date format: use RFC3339, 'YYYY-MM-DD', or formats like 'yesterday', '-2d'"), reason: "invalid_flag_value"} } // stringMapToAny converts a string-keyed string map (as produced by a @@ -538,7 +538,7 @@ func applyRawNameOverride(body json.RawMessage, name string) (json.RawMessage, e return nil, err } if obj == nil { - return nil, errors.New("body must be a JSON object") + return nil, validationError{err: errors.New("body must be a JSON object"), reason: "malformed_json"} } encoded, err := json.Marshal(name) @@ -560,7 +560,7 @@ func rejectRawNameField(body json.RawMessage, source string) error { return nil } if _, ok := obj["name"]; ok { - return fmt.Errorf("the %s must not contain a top-level \"name\" field; set the name with --name instead", source) + return validationError{err: fmt.Errorf("the %s must not contain a top-level \"name\" field; set the name with --name instead", source), reason: "invalid_body"} } return nil @@ -574,7 +574,7 @@ func rawJSONStringField(body json.RawMessage, field string) (string, error) { return "", err } if obj == nil { - return "", errors.New("body must be a JSON object") + return "", validationError{err: errors.New("body must be a JSON object"), reason: "malformed_json"} } raw, ok := obj[field]