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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/azd/cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,14 @@ func (en *envNewAction) Run(ctx context.Context) (*actions.ActionResult, error)
en.console.Message(ctx,
fmt.Sprintf("New environment '%s' was set as default", env.Name()),
)
} else if en.console.IsNoPromptMode() {
// In --no-prompt mode, auto-set the new environment as default
if err := en.azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: env.Name()}); err != nil {
return nil, fmt.Errorf("saving default environment: %w", err)
}
en.console.Message(ctx,
fmt.Sprintf("New environment '%s' created and set as default", env.Name()),
)
} else {
// Ask the user if they want to set the new environment as the default environment
msg := fmt.Sprintf("Set new environment '%s' as default environment?", env.Name())
Expand Down
50 changes: 50 additions & 0 deletions cli/azd/cmd/env_new_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// 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"
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
"github.com/azure/azure-dev/cli/azd/test/mocks"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockenv"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestEnvNewAction_NoPrompt_AutoSetsDefault(t *testing.T) {
t.Run("auto-sets default when multiple envs exist", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
mockContext.Console.SetNoPromptMode(true)

azdCtx := azdcontext.NewAzdContextWithDirectory(t.TempDir())
envMgr := &mockenv.MockEnvManager{}

testEnv := environment.New("test-env")
envMgr.On("Create", mock.Anything, mock.Anything).Return(testEnv, nil)
envMgr.On("List", mock.Anything).Return([]*environment.Description{
{Name: "existing-env"},
{Name: "test-env"},
}, nil)

action := &envNewAction{
azdCtx: azdCtx,
envManager: envMgr,
flags: &envNewFlags{global: &internal.GlobalCommandOptions{NoPrompt: true}},
args: []string{"test-env"},
console: mockContext.Console,
}

_, err := action.Run(*mockContext.Context)
require.NoError(t, err)

// Verify the default environment was set
defaultEnv, err := azdCtx.GetDefaultEnvironmentName()
require.NoError(t, err)
require.Equal(t, "test-env", defaultEnv)
})
}
7 changes: 2 additions & 5 deletions cli/azd/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,8 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
// 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 <url> --environment <name> --no-prompt")
}
// When --no-prompt is active, the environment manager will auto-generate a name from the
// working directory if no explicit name is provided, so we no longer require --environment.

var existingProject bool
if _, err := os.Stat(azdCtx.ProjectPath()); err == nil {
Expand Down
28 changes: 18 additions & 10 deletions cli/azd/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"testing"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"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"
Expand Down Expand Up @@ -47,10 +49,11 @@ func setupInitAction(t *testing.T, mockContext *mocks.MockContext, flags *initFl
lazyAzdCtx: lazy.NewLazy(func() (*azdcontext.AzdContext, error) {
return azdcontext.NewAzdContextWithDirectory(tmpDir), nil
}),
console: mockContext.Console,
cmdRun: mockContext.CommandRunner,
gitCli: gitCli,
flags: flags,
console: mockContext.Console,
cmdRun: mockContext.CommandRunner,
gitCli: gitCli,
flags: flags,
featuresManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()),
}
}

Expand Down Expand Up @@ -134,8 +137,9 @@ func TestInitNoPromptRequiresMode(t *testing.T) {
}

func TestInitFailFastMissingEnvNonInteractive(t *testing.T) {
t.Run("FailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) {
t.Run("NoLongerFailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
mockContext.Console.SetNoPromptMode(true)

flags := &initFlags{
templatePath: "owner/repo",
Expand All @@ -144,11 +148,15 @@ func TestInitFailFastMissingEnvNonInteractive(t *testing.T) {

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)
// With sensible defaults, --no-prompt --template without --environment should not
// fail with the old "--environment is required" error. The action will error or
// panic later due to missing mocks for template download, which is expected —
// we only verify the fail-fast guard was removed.
err := runActionSafe(*mockContext.Context, action)
if err != nil {
require.NotContains(t, err.Error(),
"--environment is required when running in non-interactive mode")
}
})

t.Run("DoesNotFailWhenEnvProvidedViaFlag", func(t *testing.T) {
Expand Down
46 changes: 46 additions & 0 deletions cli/azd/pkg/environment/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
Expand Down Expand Up @@ -545,7 +547,51 @@ func (m *manager) Delete(ctx context.Context, name string) error {

// ensureValidEnvironmentName ensures the environment name is valid, if it is not, an error is printed
// and the user is prompted for a new name.
// In --no-prompt mode, when no name is provided, the name is auto-generated from the working directory basename.
func (m *manager) ensureValidEnvironmentName(ctx context.Context, spec *Spec) error {
// In --no-prompt mode with no name provided, generate from working directory
if spec.Name == "" && m.console.IsNoPromptMode() {
// Prefer the project directory from azdContext, fall back to process working directory.
cwd := ""
if m.azdContext != nil {
cwd = m.azdContext.ProjectDirectory()
}

if cwd == "" {
var err error
cwd, err = os.Getwd()
if err != nil {
return fmt.Errorf(
"cannot determine working directory for default environment name: %w. "+
"Specify one explicitly with -e or as an argument", err)
}
}

dirName := filepath.Base(cwd)
cleaned := CleanName(dirName)

if cleaned == "" || cleaned == "-" || cleaned == "." || cleaned == ".." {
return fmt.Errorf(
"cannot generate valid environment name from directory '%s'. "+
"Specify one explicitly with -e or as an argument", dirName)
}

if len(cleaned) > EnvironmentNameMaxLength {
cleaned = cleaned[:EnvironmentNameMaxLength]
}

if !IsValidEnvironmentName(cleaned) {
return fmt.Errorf(
"auto-generated environment name '%s' from directory '%s' is invalid. "+
"Specify one explicitly with -e or as an argument", cleaned, dirName)
}

spec.Name = cleaned
m.console.Message(ctx, fmt.Sprintf("Using environment name: %s", spec.Name))

return nil
}

exampleText := ""
if len(spec.Examples) > 0 {
exampleText = "\n\nExamples:"
Expand Down
99 changes: 99 additions & 0 deletions cli/azd/pkg/environment/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -718,3 +720,100 @@ func Test_EnvManager_InstanceCaching(t *testing.T) {
localDataStore.AssertNumberOfCalls(t, "Get", 1) // Only initial load, not after Save
})
}

func Test_EnvManager_Create_NoPrompt_AutoName(t *testing.T) {
t.Run("generates name from working directory", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
mockContext.Console.SetNoPromptMode(true)

// Use a known directory name so we can assert the generated env name
tmpDir := t.TempDir()
knownDir := filepath.Join(tmpDir, "my-cool-project")
require.NoError(t, os.Mkdir(knownDir, 0755))

azdCtx := azdcontext.NewAzdContextWithDirectory(knownDir)
localDataStore := NewLocalFileDataStore(azdCtx, config.NewFileConfigManager(config.NewManager()))
envManager := newManagerForTest(azdCtx, mockContext.Console, localDataStore, nil)

env, err := envManager.Create(*mockContext.Context, Spec{Name: ""})
require.NoError(t, err)
require.NotNil(t, env)
require.Equal(t, "my-cool-project", env.Name())
})

t.Run("cleans special characters in directory name", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
mockContext.Console.SetNoPromptMode(true)

tmpDir := t.TempDir()
knownDir := filepath.Join(tmpDir, "my cool project!")
require.NoError(t, os.Mkdir(knownDir, 0755))

azdCtx := azdcontext.NewAzdContextWithDirectory(knownDir)
localDataStore := NewLocalFileDataStore(azdCtx, config.NewFileConfigManager(config.NewManager()))
envManager := newManagerForTest(azdCtx, mockContext.Console, localDataStore, nil)

env, err := envManager.Create(*mockContext.Context, Spec{Name: ""})
require.NoError(t, err)
require.NotNil(t, env)
require.Equal(t, "my-cool-project-", env.Name())
})

t.Run("truncates long directory names", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
mockContext.Console.SetNoPromptMode(true)

tmpDir := t.TempDir()
longName := strings.Repeat("a", 100)
knownDir := filepath.Join(tmpDir, longName)
require.NoError(t, os.Mkdir(knownDir, 0755))

azdCtx := azdcontext.NewAzdContextWithDirectory(knownDir)
localDataStore := NewLocalFileDataStore(azdCtx, config.NewFileConfigManager(config.NewManager()))
envManager := newManagerForTest(azdCtx, mockContext.Console, localDataStore, nil)

env, err := envManager.Create(*mockContext.Context, Spec{Name: ""})
require.NoError(t, err)
require.NotNil(t, env)
require.LessOrEqual(t, len(env.Name()), EnvironmentNameMaxLength)
require.Equal(t, strings.Repeat("a", EnvironmentNameMaxLength), env.Name())
})

t.Run("uses provided name even in no-prompt mode", func(t *testing.T) {
mockContext := mocks.NewMockContext(context.Background())
mockContext.Console.SetNoPromptMode(true)

envManager := createEnvManagerForManagerTest(t, mockContext)
env, err := envManager.Create(*mockContext.Context, Spec{Name: "my-explicit-env"})
require.NoError(t, err)
require.NotNil(t, env)
require.Equal(t, "my-explicit-env", env.Name())
})
}

func Test_CleanName_EdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"simple name", "my-project", "my-project"},
{"spaces replaced", "my project", "my-project"},
{"special chars replaced", "my@project!v2", "my-project-v2"},
{"dots preserved", "my.project", "my.project"},
{"underscores preserved", "my_project", "my_project"},
{"unicode replaced", "projeçt-naïve", "proje-t-na-ve"},
// Note: parentheses are valid in env names per EnvironmentNameRegexp: [a-zA-Z0-9-\(\)_\.]
{"parens preserved", "my(project)", "my(project)"},
{"empty string", "", ""},
{"all special chars", "@#$", "---"},
{"long name not truncated by CleanName", strings.Repeat("a", 100), strings.Repeat("a", 100)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CleanName(tt.input)
require.Equal(t, tt.expected, result)
})
}
}
49 changes: 35 additions & 14 deletions cli/azd/pkg/prompt/prompt_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/auth"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/ux"
)
Expand Down Expand Up @@ -161,6 +162,7 @@ type PromptService interface {
// PromptService provides methods for prompting the user to select various Azure resources.
type promptService struct {
authManager AuthManager
console input.Console
userConfigManager config.UserConfigManager
resourceService ResourceService
subscriptionManager SubscriptionManager
Expand All @@ -170,13 +172,15 @@ type promptService struct {
// NewPromptService creates a new prompt service.
func NewPromptService(
authManager AuthManager,
console input.Console,
userConfigManager config.UserConfigManager,
subscriptionManager SubscriptionManager,
resourceService ResourceService,
globalOptions *internal.GlobalCommandOptions,
) PromptService {
return &promptService{
authManager: authManager,
console: console,
userConfigManager: userConfigManager,
subscriptionManager: subscriptionManager,
resourceService: resourceService,
Expand Down Expand Up @@ -221,28 +225,45 @@ func (ps *promptService) PromptSubscription(

// Handle --no-prompt mode
if ps.globalOptions.NoPrompt {
if defaultSubscriptionId == "" {
return nil, fmt.Errorf(
"subscription selection required but cannot prompt in --no-prompt mode. " +
"Set a default subscription using 'azd config set defaults.subscription <subscription-id>'")
}

// Load subscriptions and find the default
// Load subscriptions for both default lookup and auto-selection
subscriptionList, err := ps.subscriptionManager.GetSubscriptions(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load subscriptions: %w", err)
}

for _, subscription := range subscriptionList {
if strings.EqualFold(subscription.Id, defaultSubscriptionId) {
return &subscription, nil
// If a default subscription is configured, use it
if defaultSubscriptionId != "" {
for _, subscription := range subscriptionList {
if strings.EqualFold(subscription.Id, defaultSubscriptionId) {
return &subscription, nil
}
}

return nil, fmt.Errorf(
"default subscription '%s' not found. "+
"Update your default subscription using "+
"'azd config set defaults.subscription <subscription-id>'",
defaultSubscriptionId)
}

return nil, fmt.Errorf(
"default subscription '%s' not found. "+
"Update your default subscription using 'azd config set defaults.subscription <subscription-id>'",
defaultSubscriptionId)
// No default configured — try auto-selecting if exactly one subscription exists
switch len(subscriptionList) {
case 0:
return nil, fmt.Errorf(
"no Azure subscriptions found for the current account. " +
"Verify that you're logged into the correct Azure account and tenant, " +
"and that your account has one or more active subscriptions. " +
"If needed, run 'azd auth login' to sign in.")
case 1:
ps.console.Message(ctx, fmt.Sprintf(
"Auto-selected subscription: %s (%s)",
subscriptionList[0].Name, subscriptionList[0].Id))
return &subscriptionList[0], nil
default:
return nil, fmt.Errorf(
"multiple Azure subscriptions found but running in non-interactive mode. " +
"Set a default subscription using 'azd config set defaults.subscription <subscription-id>'")
}
}

return PromptCustomResource(ctx, CustomResourceOptions[account.Subscription]{
Expand Down
Loading
Loading