diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index aa8ced7ee95..3b92cb36b3b 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -222,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 new file mode 100644 index 00000000000..78939fecaa9 --- /dev/null +++ b/cli/azd/cmd/init_test.go @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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()) + + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + action := setupInitAction(t, mockContext, 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("DoesNotFailWhenEnvProvidedViaFlag", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + 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 { + 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) { + mockContext := mocks.NewMockContext(context.Background()) + + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: false}, + } + + 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) { + mockContext := mocks.NewMockContext(context.Background()) + + flags := &initFlags{ + templatePath: "", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + 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") + } + }) +} 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