diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index e288d14f7b5..b6fc4009aac 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -263,6 +264,8 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { if existingProject { // only initialize environment when no mode is set explicitly initTypeSelect = initEnvironment + } else if i.console.IsNoPromptMode() { + return nil, &initModeRequiredError{} } else { // Prompt for init type for new projects initTypeSelect, err = promptInitType(i.console, ctx, i.featuresManager, i.configManager) @@ -880,3 +883,73 @@ func getCmdInitHelpFooter(*cobra.Command) string { ), }) } + +// initModeRequiredError is returned when azd init requires interactive prompts for initialization mode +// but --no-prompt is set. +type initModeRequiredError struct{} + +func (e *initModeRequiredError) Error() string { + return "initialization mode required when --no-prompt is set" +} + +func (e *initModeRequiredError) ToString(currentIndentation string) string { + var buf strings.Builder + separator := "──────────────────────────────────────────────────────────────" + + buf.WriteString(separator + "\n") + buf.WriteString("Init cannot continue (interactive prompts disabled)\n") + buf.WriteString(separator + "\n\n") + + buf.WriteString("Choose one:\n\n") + + buf.WriteString(" • Minimal (no template)\n") + buf.WriteString(" Creates required azd project files in the current directory.\n") + buf.WriteString(" azd init --minimal\n\n") + + buf.WriteString(" • From template\n") + buf.WriteString(" Creates a new project from an azd template.\n") + buf.WriteString(" azd template list\n") + buf.WriteString(" azd init --template --environment \n\n") + + buf.WriteString("Environment name must be globally unique (for example: myapp-dev).\n") + + return buf.String() +} + +func (e *initModeRequiredError) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Code string `json:"code"` + Message string `json:"message"` + Details struct { + Type string `json:"type"` + Options []initModeRequiredErrorOptions `json:"options"` + } `json:"details"` + }{ + Code: "initModeRequired", + Message: "Init cannot continue (interactive prompts disabled)", + Details: struct { + Type string `json:"type"` + Options []initModeRequiredErrorOptions `json:"options"` + }{ + Type: "initModeRequired", + Options: []initModeRequiredErrorOptions{ + { + Name: "minimal", + Description: "Creates required azd project files in the current directory.", + Command: "azd init --minimal", + }, + { + Name: "template", + Description: "Creates a new project from an azd template.", + Command: "azd init --template --environment ", + }, + }, + }, + }) +} + +type initModeRequiredErrorOptions struct { + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` +} diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index 78939fecaa9..9c6c54d9a3b 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -68,6 +69,70 @@ func runActionSafe(ctx context.Context, action *initAction) (retErr error) { return err } +func TestInitNoPromptRequiresMode(t *testing.T) { + t.Run("ReturnsInitNoPromptErrorWhenNoMode", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + + flags := &initFlags{ + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + action := setupInitAction(t, mockContext, flags) + + result, err := action.Run(*mockContext.Context) + require.Error(t, err) + require.Nil(t, result) + + var noPromptErr *initModeRequiredError + require.ErrorAs(t, err, &noPromptErr) + + output := noPromptErr.ToString("") + require.Contains(t, output, "Init cannot continue (interactive prompts disabled)") + require.Contains(t, output, "azd init --minimal") + require.Contains(t, output, "azd init --template") + }) + + t.Run("DoesNotErrorWhenMinimalFlagSet", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + + flags := &initFlags{ + minimal: true, + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + action := setupInitAction(t, mockContext, flags) + + err := runActionSafe(*mockContext.Context, action) + if err != nil { + var noPromptErr *initModeRequiredError + require.False(t, errors.As(err, &noPromptErr), + "should not return InitNoPromptError when --minimal is set") + } + }) + + t.Run("DoesNotErrorWhenTemplateAndEnvironmentProvided", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + flags.EnvironmentName = "myenv" + + action := setupInitAction(t, mockContext, flags) + + err := runActionSafe(*mockContext.Context, action) + if err != nil { + var noPromptErr *initModeRequiredError + require.False(t, errors.As(err, &noPromptErr), + "should not return InitNoPromptError when --template and --environment are both set") + } + }) +} + func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { t.Run("FailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index 793ad969fe3..97dec0f1396 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -108,6 +108,14 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes } m.console.Message(ctx, errMessage) + + // Print out additional text for errors that have it. + var uxItemErr ux.UxItem + if errors.As(err, &uxItemErr) { + m.console.Message(ctx, "") + m.console.MessageUxItem(ctx, uxItemErr) + return actionResult, err + } } if actionResult != nil && actionResult.Message != nil { diff --git a/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go b/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go index 898cf6e0eb4..63c08318497 100644 --- a/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go +++ b/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go @@ -335,6 +335,20 @@ func (mr *MockConsoleMockRecorder) StopSpinner(ctx, lastMessage, format any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopSpinner", reflect.TypeOf((*MockConsole)(nil).StopSpinner), ctx, lastMessage, format) } +// IsNoPromptMode mocks base method. +func (m *MockConsole) IsNoPromptMode() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsNoPromptMode") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsNoPromptMode indicates an expected call of IsNoPromptMode. +func (mr *MockConsoleMockRecorder) IsNoPromptMode() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNoPromptMode", reflect.TypeOf((*MockConsole)(nil).IsNoPromptMode)) +} + // SupportsPromptDialog mocks base method. func (m *MockConsole) SupportsPromptDialog() bool { m.ctrl.T.Helper() diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index bbdb434fa85..5ef5fa587f8 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -2356,6 +2356,11 @@ func (p *BicepProvider) ensureParameters( }{key: key, param: param}) } + // If in no-prompt mode and there are missing parameters, return an error with all missing inputs + if len(parameterPrompts) > 0 && p.console.IsNoPromptMode() { + return nil, p.buildMissingInputsError(parameterPrompts, parametersResult.envMapping) + } + if len(parameterPrompts) > 0 { if p.console.SupportsPromptDialog() { diff --git a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go new file mode 100644 index 00000000000..347a7b94adc --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azure" +) + +// InputConstraints captures the numeric/length constraints from an ARM parameter definition. +type InputConstraints struct { + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + MinValue *int `json:"minValue,omitempty"` + MaxValue *int `json:"maxValue,omitempty"` +} + +// MissingInput represents a missing required input for infrastructure provisioning. +type MissingInput struct { + Name string `json:"name"` + Type string `json:"type"` + Secure bool `json:"secure"` + Description string `json:"description,omitempty"` + EnvVarNames []string `json:"envVarNames,omitempty"` + ConfigKey string `json:"configKey,omitempty"` + AllowedValues []string `json:"allowedValues,omitempty"` + Constraints *InputConstraints `json:"constraints,omitempty"` +} + +// MissingInputsError is an error that contains information about all missing required inputs. +type MissingInputsError struct { + Inputs []MissingInput +} + +// Error implements the error interface for MissingInputsError. +func (e *MissingInputsError) Error() string { + return "missing required inputs" +} + +// ToString returns a formatted message with the missing inputs and resolution guidance. +func (e *MissingInputsError) ToString(currentIndentation string) string { + var buf strings.Builder + separator := "──────────────────────────────────────────────────────────────" + + buf.WriteString(separator + "\n") + buf.WriteString("Provision cannot continue (interactive prompts disabled)\n") + buf.WriteString(separator + "\n\n") + + count := len(e.Inputs) + if count == 1 { + buf.WriteString("1 required input is missing.\n") + } else { + buf.WriteString(fmt.Sprintf("%d required inputs are missing.\n", count)) + } + + buf.WriteString("\nMissing required inputs:\n\n") + + for _, input := range e.Inputs { + buf.WriteString(fmt.Sprintf("• %s\n", input.Name)) + + if len(input.EnvVarNames) > 0 { + buf.WriteString(fmt.Sprintf(" Environment variable: %s\n", strings.Join(input.EnvVarNames, ", "))) + } + + buf.WriteString(fmt.Sprintf(" Environment configuration key: %s\n", input.ConfigKey)) + + if input.Type != "" { + buf.WriteString(fmt.Sprintf(" Type: %s\n", input.Type)) + } + + details := constraintDetails(input) + if len(details) > 0 { + buf.WriteString(" Constraints:\n") + for _, detail := range details { + buf.WriteString(fmt.Sprintf(" %s\n", detail)) + } + } + + if input.Description != "" { + buf.WriteString(fmt.Sprintf(" Description: %s\n", input.Description)) + } + + buf.WriteString("\n") + } + + buf.WriteString(separator + "\n\n") + buf.WriteString("You can resolve these by:\n\n") + + optionNum := 1 + if e.hasEnvVars() { + buf.WriteString(fmt.Sprintf("%d) Setting environment variables\n", optionNum)) + buf.WriteString(" azd env set \n\n") + optionNum = 2 + } + + buf.WriteString(fmt.Sprintf("%d) Setting environment configuration\n", optionNum)) + buf.WriteString(" azd env config set infra.parameters. \n\n") + + buf.WriteString("Then re-run:\n") + buf.WriteString(" azd provision\n") + + return buf.String() +} + +// constraintDetails returns human-readable constraint lines for text output. +func constraintDetails(input MissingInput) []string { + var details []string + + if len(input.AllowedValues) > 0 { + details = append(details, fmt.Sprintf("Allowed values: %s", strings.Join(input.AllowedValues, ", "))) + } + + c := input.Constraints + if c == nil { + if input.Secure { + details = append(details, "Secure: true") + } + return details + } + + if c.MinLength != nil && c.MaxLength != nil { + details = append(details, fmt.Sprintf("Length: %d–%d", *c.MinLength, *c.MaxLength)) + } else if c.MinLength != nil { + details = append(details, fmt.Sprintf("Min length: %d", *c.MinLength)) + } else if c.MaxLength != nil { + details = append(details, fmt.Sprintf("Max length: %d", *c.MaxLength)) + } + + if c.MinValue != nil && c.MaxValue != nil { + details = append(details, fmt.Sprintf("Value: %d–%d", *c.MinValue, *c.MaxValue)) + } else if c.MinValue != nil { + details = append(details, fmt.Sprintf("Min value: %d", *c.MinValue)) + } else if c.MaxValue != nil { + details = append(details, fmt.Sprintf("Max value: %d", *c.MaxValue)) + } + + if input.Secure { + details = append(details, "Secure: true") + } + + return details +} + +// hasEnvVars returns true if at least one input has environment variable mappings. +func (e *MissingInputsError) hasEnvVars() bool { + for _, input := range e.Inputs { + if len(input.EnvVarNames) > 0 { + return true + } + } + return false +} + +// MarshalJSON implements json.Marshaler for MissingInputsError. +func (e *MissingInputsError) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Code string `json:"code"` + Message string `json:"message"` + Details struct { + Type string `json:"type"` + Inputs []MissingInput `json:"inputs"` + } `json:"details"` + }{ + Code: "provisionMissingInputs", + Message: "Provision cannot continue (interactive prompts disabled)", + Details: struct { + Type string `json:"type"` + Inputs []MissingInput `json:"inputs"` + }{ + Type: "provisionMissingInputs", + Inputs: e.Inputs, + }, + }) +} + +// buildMissingInputsError creates a MissingInputsError from parameter prompts and environment mapping. +func (p *BicepProvider) buildMissingInputsError( + parameterPrompts []struct { + key string + param azure.ArmTemplateParameterDefinition + }, + envMapping map[string][]string, +) *MissingInputsError { + var inputs []MissingInput + + for _, prompt := range parameterPrompts { + param := prompt.param + + // Normalize type for display (securestring → string, secureobject → object) + displayType := param.Type + if strings.EqualFold(displayType, "securestring") { + displayType = "string" + } else if strings.EqualFold(displayType, "secureobject") { + displayType = "object" + } + + // Get description if available + description := "" + if desc, ok := param.Description(); ok { + description = desc + } + + // Get allowed values if specified + var allowedValues []string + if param.AllowedValues != nil { + for _, val := range *param.AllowedValues { + allowedValues = append(allowedValues, fmt.Sprintf("%v", val)) + } + } + + input := MissingInput{ + Name: prompt.key, + Type: displayType, + Secure: param.Secure(), + Description: description, + EnvVarNames: envMapping[prompt.key], + ConfigKey: fmt.Sprintf("infra.parameters.%s", prompt.key), + AllowedValues: allowedValues, + Constraints: &InputConstraints{ + MinLength: param.MinLength, + MaxLength: param.MaxLength, + MinValue: param.MinValue, + MaxValue: param.MaxValue, + }, + } + + inputs = append(inputs, input) + } + + return &MissingInputsError{ + Inputs: inputs, + } +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go new file mode 100644 index 00000000000..917e8839e74 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go @@ -0,0 +1,316 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "encoding/json" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azure" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMissingInputsError_Error_SingleInput(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "location", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION"}, + ConfigKey: "infra.parameters.location", + AllowedValues: []string{"eastus", "westus"}, + Description: "The Azure region for resources", + Secure: false, + }, + }, + } + + assert.Equal(t, "missing required inputs", err.Error()) + + output := err.ToString("") + + assert.Contains(t, output, "Provision cannot continue (interactive prompts disabled)") + assert.Contains(t, output, "1 required input is missing") + assert.Contains(t, output, "• location") + assert.Contains(t, output, "Environment variable: AZURE_LOCATION") + assert.Contains(t, output, "Environment configuration key: infra.parameters.location") + assert.Contains(t, output, "Type: string") + assert.Contains(t, output, "Allowed values: eastus, westus") + assert.Contains(t, output, "Description: The Azure region for resources") + assert.Contains(t, output, "You can resolve these by:") + assert.Contains(t, output, "azd env set") + assert.Contains(t, output, "azd env config set") + assert.Contains(t, output, "azd provision") +} + +func TestMissingInputsError_Error_MultipleInputs(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "location", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION"}, + ConfigKey: "infra.parameters.location", + }, + { + Name: "apiKey", + Type: "string", + EnvVarNames: []string{"API_KEY"}, + ConfigKey: "infra.parameters.apiKey", + Secure: true, + }, + }, + } + + output := err.ToString("") + + assert.Contains(t, output, "2 required inputs are missing") + assert.Contains(t, output, "• location") + assert.Contains(t, output, "• apiKey") + assert.Contains(t, output, "Environment variable: AZURE_LOCATION") + assert.Contains(t, output, "Environment variable: API_KEY") +} + +func TestMissingInputsError_Error_NoEnvVars(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "param1", + Type: "string", + ConfigKey: "infra.parameters.param1", + }, + }, + } + + output := err.ToString("") + + assert.NotContains(t, output, "1) Setting environment variables") + assert.Contains(t, output, "1) Setting environment configuration") + assert.NotContains(t, output, "2) Setting environment configuration") +} + +func TestConstraintDetails(t *testing.T) { + tests := []struct { + name string + input MissingInput + expected []string + }{ + { + name: "AllowedValues", + input: MissingInput{ + AllowedValues: []string{"a", "b", "c"}, + }, + expected: []string{"Allowed values: a, b, c"}, + }, + { + name: "MinAndMaxLength", + input: MissingInput{ + Constraints: &InputConstraints{MinLength: intPtr(5), MaxLength: intPtr(20)}, + }, + expected: []string{"Length: 5–20"}, + }, + { + name: "MinLengthOnly", + input: MissingInput{ + Constraints: &InputConstraints{MinLength: intPtr(3)}, + }, + expected: []string{"Min length: 3"}, + }, + { + name: "MaxLengthOnly", + input: MissingInput{ + Constraints: &InputConstraints{MaxLength: intPtr(100)}, + }, + expected: []string{"Max length: 100"}, + }, + { + name: "MinAndMaxValue", + input: MissingInput{ + Constraints: &InputConstraints{MinValue: intPtr(1), MaxValue: intPtr(100)}, + }, + expected: []string{"Value: 1–100"}, + }, + { + name: "Secure", + input: MissingInput{ + Secure: true, + }, + expected: []string{"Secure: true"}, + }, + { + name: "NoConstraints", + input: MissingInput{}, + expected: nil, + }, + { + name: "AllConstraints", + input: MissingInput{ + AllowedValues: []string{"x"}, + Secure: true, + Constraints: &InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, + }, + expected: []string{"Allowed values: x", "Length: 1–50", "Secure: true"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := constraintDetails(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestBuildConstraints_SecureType(t *testing.T) { + param := azure.ArmTemplateParameterDefinition{ + Type: "securestring", + } + + prompts := []struct { + key string + param azure.ArmTemplateParameterDefinition + }{{key: "secret", param: param}} + + p := &BicepProvider{} + result := p.buildMissingInputsError(prompts, nil) + + require.Len(t, result.Inputs, 1) + assert.Equal(t, "string", result.Inputs[0].Type) + assert.True(t, result.Inputs[0].Secure) +} + +func TestBuildConstraints_SecureObjectType(t *testing.T) { + param := azure.ArmTemplateParameterDefinition{ + Type: "secureobject", + } + + prompts := []struct { + key string + param azure.ArmTemplateParameterDefinition + }{{key: "obj", param: param}} + + p := &BicepProvider{} + result := p.buildMissingInputsError(prompts, nil) + + require.Len(t, result.Inputs, 1) + assert.Equal(t, "object", result.Inputs[0].Type) + assert.True(t, result.Inputs[0].Secure) +} + +func TestMissingInputsError_Error_WithAllDetails(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "resourceGroupLocation", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION", "AZURE_DEPLOYMENT_REGION"}, + ConfigKey: "infra.parameters.resourceGroupLocation", + AllowedValues: []string{"eastus", "westus", "centralus"}, + Description: "Location for all resources", + Constraints: &InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, + }, + { + Name: "storageAccountKey", + Type: "string", + EnvVarNames: []string{"STORAGE_KEY"}, + ConfigKey: "infra.parameters.storageAccountKey", + Description: "Storage account access key", + Secure: true, + }, + }, + } + + output := err.ToString("") + + assert.Contains(t, output, "Provision cannot continue (interactive prompts disabled)") + assert.Contains(t, output, "2 required inputs are missing") + + assert.Contains(t, output, "• resourceGroupLocation") + assert.Contains(t, output, "Environment variable: AZURE_LOCATION, AZURE_DEPLOYMENT_REGION") + assert.Contains(t, output, "Environment configuration key: infra.parameters.resourceGroupLocation") + assert.Contains(t, output, "Allowed values: eastus, westus, centralus") + assert.Contains(t, output, "Length: 1–50") + + assert.Contains(t, output, "• storageAccountKey") + assert.Contains(t, output, "Environment variable: STORAGE_KEY") + assert.Contains(t, output, "Secure: true") + + assert.Contains(t, output, "You can resolve these by:") + assert.Contains(t, output, "1) Setting environment variables") + assert.Contains(t, output, "2) Setting environment configuration") + assert.Contains(t, output, "Then re-run:") + assert.Contains(t, output, "azd provision") +} + +func TestMissingInputsError_MarshalJSON(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "location", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION"}, + ConfigKey: "infra.parameters.location", + Description: "The Azure region", + Constraints: &InputConstraints{MinLength: intPtr(1)}, + }, + }, + } + + jsonData, marshalErr := err.MarshalJSON() + require.NoError(t, marshalErr) + + var result struct { + Code string `json:"code"` + Message string `json:"message"` + Details struct { + Type string `json:"type"` + Inputs []MissingInput `json:"inputs"` + } `json:"details"` + } + require.NoError(t, json.Unmarshal(jsonData, &result)) + + assert.Equal(t, "provisionMissingInputs", result.Code) + assert.Equal(t, "Provision cannot continue (interactive prompts disabled)", result.Message) + assert.Equal(t, "provisionMissingInputs", result.Details.Type) + require.Len(t, result.Details.Inputs, 1) + + input := result.Details.Inputs[0] + assert.Equal(t, "location", input.Name) + assert.Equal(t, "string", input.Type) + assert.Equal(t, "infra.parameters.location", input.ConfigKey) + assert.Equal(t, []string{"AZURE_LOCATION"}, input.EnvVarNames) + assert.Equal(t, "The Azure region", input.Description) + require.NotNil(t, input.Constraints) + require.NotNil(t, input.Constraints.MinLength) + assert.Equal(t, 1, *input.Constraints.MinLength) + assert.Nil(t, input.Constraints.MaxLength) +} + +func TestMissingInputsError_MarshalJSON_OmitsEmptyConstraints(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "flag", + Type: "bool", + ConfigKey: "infra.parameters.flag", + }, + }, + } + + jsonData, marshalErr := err.MarshalJSON() + require.NoError(t, marshalErr) + + // Verify that constraints key is omitted entirely when nil + raw := string(jsonData) + assert.NotContains(t, raw, "constraints") + assert.NotContains(t, raw, "minLength") + assert.NotContains(t, raw, "maxLength") + assert.NotContains(t, raw, "minValue") + assert.NotContains(t, raw, "maxValue") +} + +func intPtr(v int) *int { + return &v +} diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 1356ffc1670..6d1e224637e 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -107,6 +107,8 @@ type Console interface { // If false, the spinner is non-interactive, which means messages are rendered as a new console message on each // call to ShowSpinner, even when the title is unchanged. IsSpinnerInteractive() bool + // IsNoPromptMode returns true when --no-prompt is active and interactive prompts are disabled. + IsNoPromptMode() bool SupportsPromptDialog() bool PromptDialog(ctx context.Context, dialog PromptDialog) (map[string]any, error) // Prompts the user for a single value @@ -558,6 +560,10 @@ func promptFromOptions(options ConsoleOptions) survey.Prompt { // 0 in the sentinel), followed by a new line. const afterIoSentinel = "0\n" +func (c *AskerConsole) IsNoPromptMode() bool { + return c.noPrompt +} + func (c *AskerConsole) SupportsPromptDialog() bool { return c.promptClient != nil && !c.noPromptDialog } diff --git a/cli/azd/test/mocks/mockinput/mock_console.go b/cli/azd/test/mocks/mockinput/mock_console.go index 861cfdbe90a..a01abed1aa5 100644 --- a/cli/azd/test/mocks/mockinput/mock_console.go +++ b/cli/azd/test/mocks/mockinput/mock_console.go @@ -37,6 +37,7 @@ type MockConsole struct { expressions []*MockConsoleExpression log []string spinnerOps []SpinnerOp + noPrompt bool } func NewMockConsole() *MockConsole { @@ -138,6 +139,14 @@ func (c *MockConsole) WaitForEnter() { func (c *MockConsole) EnsureBlankLine(context context.Context) { } +func (c *MockConsole) SetNoPromptMode(noPrompt bool) { + c.noPrompt = noPrompt +} + +func (c *MockConsole) IsNoPromptMode() bool { + return c.noPrompt +} + func (c *MockConsole) SupportsPromptDialog() bool { return false }