From f3162695cf4f264519e7806eeed8d36759973f08 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 18 Feb 2026 12:36:32 -0500 Subject: [PATCH 1/3] Fail fast in azd init when --environment missing non-interactively Fixes #6775 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/init.go | 8 +++++ cli/azd/cmd/init_test.go | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 cli/azd/cmd/init_test.go diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index aa8ced7ee95..4fda1b1a03b 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -194,6 +194,14 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { "using branch argument (-b or --branch) requires a template argument (--template or -t) to be specified") } + // Fail fast when running non-interactively with --template but without --environment + // to avoid downloading the template and then failing at the environment name prompt. + if i.flags.global.NoPrompt && i.flags.templatePath != "" && i.flags.EnvironmentName == "" { + return nil, errors.New( + "--environment is required when running in non-interactive mode (--no-prompt) with --template. " + + "Use: azd init --template --environment --no-prompt") + } + // ensure that git is available if err := tools.EnsureInstalled(ctx, []tools.ExternalTool{i.gitCli}...); err != nil { return nil, err diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go new file mode 100644 index 00000000000..fbe4b92986c --- /dev/null +++ b/cli/azd/cmd/init_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" +) + +func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { + t.Run("FailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + action := &initAction{ + lazyAzdCtx: lazy.NewLazy(func() (*azdcontext.AzdContext, error) { + return nil, nil + }), + console: mockContext.Console, + flags: flags, + } + + result, err := action.Run(*mockContext.Context) + require.Error(t, err) + require.Contains(t, err.Error(), + "--environment is required when running in non-interactive mode") + require.Nil(t, result) + }) + + t.Run("DoesNotFailWhenEnvProvided", func(t *testing.T) { + // Verify the condition: with env name set, no fail-fast error + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + flags.EnvironmentName = "myenv" + + shouldFailFast := flags.global.NoPrompt && flags.templatePath != "" && flags.EnvironmentName == "" + require.False(t, shouldFailFast) + }) + + t.Run("DoesNotFailInInteractiveMode", func(t *testing.T) { + // Verify the condition: in interactive mode, no fail-fast error + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: false}, + } + + shouldFailFast := flags.global.NoPrompt && flags.templatePath != "" && flags.EnvironmentName == "" + require.False(t, shouldFailFast) + }) + + t.Run("DoesNotFailWithoutTemplate", func(t *testing.T) { + // Verify the condition: without template, no fail-fast error + flags := &initFlags{ + templatePath: "", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + shouldFailFast := flags.global.NoPrompt && flags.templatePath != "" && flags.EnvironmentName == "" + require.False(t, shouldFailFast) + }) +} From 01fb5bb4a48f8950a43c88338522c85c9bd308fc Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 18 Feb 2026 12:51:46 -0500 Subject: [PATCH 2/3] Fix fail-fast check ordering: run after .env resolution Move the non-interactive fail-fast check (--template without --environment) to after .env loading and AZURE_ENV_NAME resolution. Previously the check ran before godotenv.Overload(), causing false failures when AZURE_ENV_NAME was provided via a .env file. Update tests to exercise the actual Run() flow through the fail-fast check, including a new test case for .env-provided environment names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/init.go | 17 +++--- cli/azd/cmd/init_test.go | 123 +++++++++++++++++++++++++++++++++------ 2 files changed, 115 insertions(+), 25 deletions(-) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index 4fda1b1a03b..3b92cb36b3b 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -194,14 +194,6 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { "using branch argument (-b or --branch) requires a template argument (--template or -t) to be specified") } - // Fail fast when running non-interactively with --template but without --environment - // to avoid downloading the template and then failing at the environment name prompt. - if i.flags.global.NoPrompt && i.flags.templatePath != "" && i.flags.EnvironmentName == "" { - return nil, errors.New( - "--environment is required when running in non-interactive mode (--no-prompt) with --template. " + - "Use: azd init --template --environment --no-prompt") - } - // ensure that git is available if err := tools.EnsureInstalled(ctx, []tools.ExternalTool{i.gitCli}...); err != nil { return nil, err @@ -230,6 +222,15 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { i.flags.EnvFlag.EnvironmentName = os.Getenv(environment.EnvNameEnvVarName) } + // Fail fast when running non-interactively with --template but without --environment + // to avoid downloading the template and then failing at the environment name prompt. + // This check runs after .env loading so that AZURE_ENV_NAME from .env is considered. + if i.flags.global.NoPrompt && i.flags.templatePath != "" && i.flags.EnvironmentName == "" { + return nil, errors.New( + "--environment is required when running in non-interactive mode (--no-prompt) with --template. " + + "Use: azd init --template --environment --no-prompt") + } + var existingProject bool if _, err := os.Stat(azdCtx.ProjectPath()); err == nil { existingProject = true diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index fbe4b92986c..78939fecaa9 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -5,15 +5,69 @@ package cmd import ( "context" + "fmt" + "os" + "path/filepath" + "strings" "testing" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/tools/git" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" ) +// setupInitAction creates an initAction wired with mocks that pass git-install checks. +// The working directory is changed to a temp dir so that .env loading and azdcontext work. +func setupInitAction(t *testing.T, mockContext *mocks.MockContext, flags *initFlags) *initAction { + t.Helper() + + // Work in a temp directory so os.Getwd / godotenv.Overload operate in isolation. + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { os.Chdir(origDir) }) + + // Mock git so tools.EnsureInstalled succeeds. + mockContext.CommandRunner.MockToolInPath("git", nil) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "git") && strings.Contains(command, "--version") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(0, "git version 2.42.0", ""), nil + }) + + gitCli := git.NewCli(mockContext.CommandRunner) + + return &initAction{ + lazyAzdCtx: lazy.NewLazy(func() (*azdcontext.AzdContext, error) { + return azdcontext.NewAzdContextWithDirectory(tmpDir), nil + }), + console: mockContext.Console, + cmdRun: mockContext.CommandRunner, + gitCli: gitCli, + flags: flags, + } +} + +// runActionSafe calls action.Run and returns the error. If Run panics (because +// later stages lack mocks), the panic is recovered and a nil error is returned +// — the test only cares that the fail-fast check did not fire. +func runActionSafe(ctx context.Context, action *initAction) (retErr error) { + defer func() { + if r := recover(); r != nil { + // A panic means we got past the fail-fast check — treat as success. + retErr = fmt.Errorf("panic (past fail-fast): %v", r) + } + }() + + _, err := action.Run(ctx) + return err +} + func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { t.Run("FailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) @@ -23,13 +77,7 @@ func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { global: &internal.GlobalCommandOptions{NoPrompt: true}, } - action := &initAction{ - lazyAzdCtx: lazy.NewLazy(func() (*azdcontext.AzdContext, error) { - return nil, nil - }), - console: mockContext.Console, - flags: flags, - } + action := setupInitAction(t, mockContext, flags) result, err := action.Run(*mockContext.Context) require.Error(t, err) @@ -38,37 +86,78 @@ func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { require.Nil(t, result) }) - t.Run("DoesNotFailWhenEnvProvided", func(t *testing.T) { - // Verify the condition: with env name set, no fail-fast error + t.Run("DoesNotFailWhenEnvProvidedViaFlag", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ templatePath: "owner/repo", global: &internal.GlobalCommandOptions{NoPrompt: true}, } flags.EnvironmentName = "myenv" - shouldFailFast := flags.global.NoPrompt && flags.templatePath != "" && flags.EnvironmentName == "" - require.False(t, shouldFailFast) + action := setupInitAction(t, mockContext, flags) + + err := runActionSafe(*mockContext.Context, action) + if err != nil { + require.NotContains(t, err.Error(), + "--environment is required when running in non-interactive mode") + } + }) + + t.Run("DoesNotFailWhenEnvProvidedViaDotEnv", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + action := setupInitAction(t, mockContext, flags) + + // Write a .env file in the temp working directory with AZURE_ENV_NAME set. + wd, err := os.Getwd() + require.NoError(t, err) + envFile := filepath.Join(wd, ".env") + require.NoError(t, os.WriteFile(envFile, []byte("AZURE_ENV_NAME=from-dotenv\n"), 0600)) + + err = runActionSafe(*mockContext.Context, action) + if err != nil { + require.NotContains(t, err.Error(), + "--environment is required when running in non-interactive mode") + } }) t.Run("DoesNotFailInInteractiveMode", func(t *testing.T) { - // Verify the condition: in interactive mode, no fail-fast error + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ templatePath: "owner/repo", global: &internal.GlobalCommandOptions{NoPrompt: false}, } - shouldFailFast := flags.global.NoPrompt && flags.templatePath != "" && flags.EnvironmentName == "" - require.False(t, shouldFailFast) + action := setupInitAction(t, mockContext, flags) + + err := runActionSafe(*mockContext.Context, action) + if err != nil { + require.NotContains(t, err.Error(), + "--environment is required when running in non-interactive mode") + } }) t.Run("DoesNotFailWithoutTemplate", func(t *testing.T) { - // Verify the condition: without template, no fail-fast error + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ templatePath: "", global: &internal.GlobalCommandOptions{NoPrompt: true}, } - shouldFailFast := flags.global.NoPrompt && flags.templatePath != "" && flags.EnvironmentName == "" - require.False(t, shouldFailFast) + action := setupInitAction(t, mockContext, flags) + + err := runActionSafe(*mockContext.Context, action) + if err != nil { + require.NotContains(t, err.Error(), + "--environment is required when running in non-interactive mode") + } }) } From 3aa2def0be4a3e8e9cdf00ba625afa19ece444ca Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 18 Feb 2026 15:36:54 -0500 Subject: [PATCH 3/3] Fix Test_CLI_Init_WithCwdAutoCreate for fail-fast validation The fail-fast check now requires --environment when using --no-prompt with --template. Update the test to pass -e test-env so the template download and directory creation proceed as expected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/test/functional/init_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cli/azd/test/functional/init_test.go b/cli/azd/test/functional/init_test.go index f63ecc39857..c94a0f73e42 100644 --- a/cli/azd/test/functional/init_test.go +++ b/cli/azd/test/functional/init_test.go @@ -301,12 +301,12 @@ func Test_CLI_Init_WithCwdAutoCreate(t *testing.T) { { name: "single level directory", subDir: "new-project", - args: []string{"init", "-t", "azure-samples/todo-nodejs-mongo", "--no-prompt"}, + args: []string{"init", "-t", "azure-samples/todo-nodejs-mongo", "--no-prompt", "-e", "test-env"}, }, { name: "nested directory", subDir: "parent/child/project", - args: []string{"init", "-t", "azure-samples/todo-nodejs-mongo", "--no-prompt"}, + args: []string{"init", "-t", "azure-samples/todo-nodejs-mongo", "--no-prompt", "-e", "test-env"}, }, } @@ -328,8 +328,7 @@ func Test_CLI_Init_WithCwdAutoCreate(t *testing.T) { require.NoDirExists(t, targetDir) // Run the command - // Note: We expect an error because --no-prompt will fail on environment name prompt - // but the directory creation should succeed before that + // The -e flag provides an environment name to avoid fail-fast when using --no-prompt with --template cli.RunCommand(ctx, args...) // Verify the directory was created