Skip to content

Commit 90f1b34

Browse files
spboyerCopilot
andauthored
feat: add sensible defaults for azd env new / init in --no-prompt mode (#7016)
* feat: add sensible defaults for azd env new / init in --no-prompt mode When --no-prompt is active: - Auto-generate environment name from working directory basename (via CleanName sanitization) when no name is provided - Auto-select Azure subscription when only one exists; check defaults.subscription config first, then fall back to auto-select - Auto-set new environment as default (skip Confirm prompt) - Remove hard error requiring --environment with --template This enables non-interactive CI/CD flows and extensions to use azd env new / azd init without implementing custom --no-prompt logic. Fixes #6934 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address PR review feedback - Use azdContext.ProjectDirectory() instead of os.Getwd() for env name generation, with fallback to os.Getwd() when azdContext is nil - Add '..' to degenerate name validation checks - Improve no-subscriptions error message to not assume unauthenticated - Document that parens are valid per EnvironmentNameRegexp in test - Clarify init test comment re: expected panics from unmocked deps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: retrigger pipeline checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0815bea commit 90f1b34

File tree

8 files changed

+372
-32
lines changed

8 files changed

+372
-32
lines changed

cli/azd/cmd/env.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,14 @@ func (en *envNewAction) Run(ctx context.Context) (*actions.ActionResult, error)
975975
en.console.Message(ctx,
976976
fmt.Sprintf("New environment '%s' was set as default", env.Name()),
977977
)
978+
} else if en.console.IsNoPromptMode() {
979+
// In --no-prompt mode, auto-set the new environment as default
980+
if err := en.azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: env.Name()}); err != nil {
981+
return nil, fmt.Errorf("saving default environment: %w", err)
982+
}
983+
en.console.Message(ctx,
984+
fmt.Sprintf("New environment '%s' created and set as default", env.Name()),
985+
)
978986
} else {
979987
// Ask the user if they want to set the new environment as the default environment
980988
msg := fmt.Sprintf("Set new environment '%s' as default environment?", env.Name())

cli/azd/cmd/env_new_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
"github.com/azure/azure-dev/cli/azd/internal"
11+
"github.com/azure/azure-dev/cli/azd/pkg/environment"
12+
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
13+
"github.com/azure/azure-dev/cli/azd/test/mocks"
14+
"github.com/azure/azure-dev/cli/azd/test/mocks/mockenv"
15+
"github.com/stretchr/testify/mock"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestEnvNewAction_NoPrompt_AutoSetsDefault(t *testing.T) {
20+
t.Run("auto-sets default when multiple envs exist", func(t *testing.T) {
21+
mockContext := mocks.NewMockContext(context.Background())
22+
mockContext.Console.SetNoPromptMode(true)
23+
24+
azdCtx := azdcontext.NewAzdContextWithDirectory(t.TempDir())
25+
envMgr := &mockenv.MockEnvManager{}
26+
27+
testEnv := environment.New("test-env")
28+
envMgr.On("Create", mock.Anything, mock.Anything).Return(testEnv, nil)
29+
envMgr.On("List", mock.Anything).Return([]*environment.Description{
30+
{Name: "existing-env"},
31+
{Name: "test-env"},
32+
}, nil)
33+
34+
action := &envNewAction{
35+
azdCtx: azdCtx,
36+
envManager: envMgr,
37+
flags: &envNewFlags{global: &internal.GlobalCommandOptions{NoPrompt: true}},
38+
args: []string{"test-env"},
39+
console: mockContext.Console,
40+
}
41+
42+
_, err := action.Run(*mockContext.Context)
43+
require.NoError(t, err)
44+
45+
// Verify the default environment was set
46+
defaultEnv, err := azdCtx.GetDefaultEnvironmentName()
47+
require.NoError(t, err)
48+
require.Equal(t, "test-env", defaultEnv)
49+
})
50+
}

cli/azd/cmd/init.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,8 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
226226
// Fail fast when running non-interactively with --template but without --environment
227227
// to avoid downloading the template and then failing at the environment name prompt.
228228
// This check runs after .env loading so that AZURE_ENV_NAME from .env is considered.
229-
if i.flags.global.NoPrompt && i.flags.templatePath != "" && i.flags.EnvironmentName == "" {
230-
return nil, errors.New(
231-
"--environment is required when running in non-interactive mode (--no-prompt) with --template. " +
232-
"Use: azd init --template <url> --environment <name> --no-prompt")
233-
}
229+
// When --no-prompt is active, the environment manager will auto-generate a name from the
230+
// working directory if no explicit name is provided, so we no longer require --environment.
234231

235232
var existingProject bool
236233
if _, err := os.Stat(azdCtx.ProjectPath()); err == nil {

cli/azd/cmd/init_test.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"testing"
1414

1515
"github.com/azure/azure-dev/cli/azd/internal"
16+
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
17+
"github.com/azure/azure-dev/cli/azd/pkg/config"
1618
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
1719
"github.com/azure/azure-dev/cli/azd/pkg/exec"
1820
"github.com/azure/azure-dev/cli/azd/pkg/lazy"
@@ -47,10 +49,11 @@ func setupInitAction(t *testing.T, mockContext *mocks.MockContext, flags *initFl
4749
lazyAzdCtx: lazy.NewLazy(func() (*azdcontext.AzdContext, error) {
4850
return azdcontext.NewAzdContextWithDirectory(tmpDir), nil
4951
}),
50-
console: mockContext.Console,
51-
cmdRun: mockContext.CommandRunner,
52-
gitCli: gitCli,
53-
flags: flags,
52+
console: mockContext.Console,
53+
cmdRun: mockContext.CommandRunner,
54+
gitCli: gitCli,
55+
flags: flags,
56+
featuresManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()),
5457
}
5558
}
5659

@@ -134,8 +137,9 @@ func TestInitNoPromptRequiresMode(t *testing.T) {
134137
}
135138

136139
func TestInitFailFastMissingEnvNonInteractive(t *testing.T) {
137-
t.Run("FailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) {
140+
t.Run("NoLongerFailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) {
138141
mockContext := mocks.NewMockContext(context.Background())
142+
mockContext.Console.SetNoPromptMode(true)
139143

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

145149
action := setupInitAction(t, mockContext, flags)
146150

147-
result, err := action.Run(*mockContext.Context)
148-
require.Error(t, err)
149-
require.Contains(t, err.Error(),
150-
"--environment is required when running in non-interactive mode")
151-
require.Nil(t, result)
151+
// With sensible defaults, --no-prompt --template without --environment should not
152+
// fail with the old "--environment is required" error. The action will error or
153+
// panic later due to missing mocks for template download, which is expected —
154+
// we only verify the fail-fast guard was removed.
155+
err := runActionSafe(*mockContext.Context, action)
156+
if err != nil {
157+
require.NotContains(t, err.Error(),
158+
"--environment is required when running in non-interactive mode")
159+
}
152160
})
153161

154162
t.Run("DoesNotFailWhenEnvProvidedViaFlag", func(t *testing.T) {

cli/azd/pkg/environment/manager.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"os"
11+
"path/filepath"
1012
"slices"
1113
"strings"
1214
"sync"
@@ -545,7 +547,51 @@ func (m *manager) Delete(ctx context.Context, name string) error {
545547

546548
// ensureValidEnvironmentName ensures the environment name is valid, if it is not, an error is printed
547549
// and the user is prompted for a new name.
550+
// In --no-prompt mode, when no name is provided, the name is auto-generated from the working directory basename.
548551
func (m *manager) ensureValidEnvironmentName(ctx context.Context, spec *Spec) error {
552+
// In --no-prompt mode with no name provided, generate from working directory
553+
if spec.Name == "" && m.console.IsNoPromptMode() {
554+
// Prefer the project directory from azdContext, fall back to process working directory.
555+
cwd := ""
556+
if m.azdContext != nil {
557+
cwd = m.azdContext.ProjectDirectory()
558+
}
559+
560+
if cwd == "" {
561+
var err error
562+
cwd, err = os.Getwd()
563+
if err != nil {
564+
return fmt.Errorf(
565+
"cannot determine working directory for default environment name: %w. "+
566+
"Specify one explicitly with -e or as an argument", err)
567+
}
568+
}
569+
570+
dirName := filepath.Base(cwd)
571+
cleaned := CleanName(dirName)
572+
573+
if cleaned == "" || cleaned == "-" || cleaned == "." || cleaned == ".." {
574+
return fmt.Errorf(
575+
"cannot generate valid environment name from directory '%s'. "+
576+
"Specify one explicitly with -e or as an argument", dirName)
577+
}
578+
579+
if len(cleaned) > EnvironmentNameMaxLength {
580+
cleaned = cleaned[:EnvironmentNameMaxLength]
581+
}
582+
583+
if !IsValidEnvironmentName(cleaned) {
584+
return fmt.Errorf(
585+
"auto-generated environment name '%s' from directory '%s' is invalid. "+
586+
"Specify one explicitly with -e or as an argument", cleaned, dirName)
587+
}
588+
589+
spec.Name = cleaned
590+
m.console.Message(ctx, fmt.Sprintf("Using environment name: %s", spec.Name))
591+
592+
return nil
593+
}
594+
549595
exampleText := ""
550596
if len(spec.Examples) > 0 {
551597
exampleText = "\n\nExamples:"

cli/azd/pkg/environment/manager_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"os"
11+
"path/filepath"
1012
"strings"
1113
"testing"
1214

@@ -718,3 +720,100 @@ func Test_EnvManager_InstanceCaching(t *testing.T) {
718720
localDataStore.AssertNumberOfCalls(t, "Get", 1) // Only initial load, not after Save
719721
})
720722
}
723+
724+
func Test_EnvManager_Create_NoPrompt_AutoName(t *testing.T) {
725+
t.Run("generates name from working directory", func(t *testing.T) {
726+
mockContext := mocks.NewMockContext(context.Background())
727+
mockContext.Console.SetNoPromptMode(true)
728+
729+
// Use a known directory name so we can assert the generated env name
730+
tmpDir := t.TempDir()
731+
knownDir := filepath.Join(tmpDir, "my-cool-project")
732+
require.NoError(t, os.Mkdir(knownDir, 0755))
733+
734+
azdCtx := azdcontext.NewAzdContextWithDirectory(knownDir)
735+
localDataStore := NewLocalFileDataStore(azdCtx, config.NewFileConfigManager(config.NewManager()))
736+
envManager := newManagerForTest(azdCtx, mockContext.Console, localDataStore, nil)
737+
738+
env, err := envManager.Create(*mockContext.Context, Spec{Name: ""})
739+
require.NoError(t, err)
740+
require.NotNil(t, env)
741+
require.Equal(t, "my-cool-project", env.Name())
742+
})
743+
744+
t.Run("cleans special characters in directory name", func(t *testing.T) {
745+
mockContext := mocks.NewMockContext(context.Background())
746+
mockContext.Console.SetNoPromptMode(true)
747+
748+
tmpDir := t.TempDir()
749+
knownDir := filepath.Join(tmpDir, "my cool project!")
750+
require.NoError(t, os.Mkdir(knownDir, 0755))
751+
752+
azdCtx := azdcontext.NewAzdContextWithDirectory(knownDir)
753+
localDataStore := NewLocalFileDataStore(azdCtx, config.NewFileConfigManager(config.NewManager()))
754+
envManager := newManagerForTest(azdCtx, mockContext.Console, localDataStore, nil)
755+
756+
env, err := envManager.Create(*mockContext.Context, Spec{Name: ""})
757+
require.NoError(t, err)
758+
require.NotNil(t, env)
759+
require.Equal(t, "my-cool-project-", env.Name())
760+
})
761+
762+
t.Run("truncates long directory names", func(t *testing.T) {
763+
mockContext := mocks.NewMockContext(context.Background())
764+
mockContext.Console.SetNoPromptMode(true)
765+
766+
tmpDir := t.TempDir()
767+
longName := strings.Repeat("a", 100)
768+
knownDir := filepath.Join(tmpDir, longName)
769+
require.NoError(t, os.Mkdir(knownDir, 0755))
770+
771+
azdCtx := azdcontext.NewAzdContextWithDirectory(knownDir)
772+
localDataStore := NewLocalFileDataStore(azdCtx, config.NewFileConfigManager(config.NewManager()))
773+
envManager := newManagerForTest(azdCtx, mockContext.Console, localDataStore, nil)
774+
775+
env, err := envManager.Create(*mockContext.Context, Spec{Name: ""})
776+
require.NoError(t, err)
777+
require.NotNil(t, env)
778+
require.LessOrEqual(t, len(env.Name()), EnvironmentNameMaxLength)
779+
require.Equal(t, strings.Repeat("a", EnvironmentNameMaxLength), env.Name())
780+
})
781+
782+
t.Run("uses provided name even in no-prompt mode", func(t *testing.T) {
783+
mockContext := mocks.NewMockContext(context.Background())
784+
mockContext.Console.SetNoPromptMode(true)
785+
786+
envManager := createEnvManagerForManagerTest(t, mockContext)
787+
env, err := envManager.Create(*mockContext.Context, Spec{Name: "my-explicit-env"})
788+
require.NoError(t, err)
789+
require.NotNil(t, env)
790+
require.Equal(t, "my-explicit-env", env.Name())
791+
})
792+
}
793+
794+
func Test_CleanName_EdgeCases(t *testing.T) {
795+
tests := []struct {
796+
name string
797+
input string
798+
expected string
799+
}{
800+
{"simple name", "my-project", "my-project"},
801+
{"spaces replaced", "my project", "my-project"},
802+
{"special chars replaced", "my@project!v2", "my-project-v2"},
803+
{"dots preserved", "my.project", "my.project"},
804+
{"underscores preserved", "my_project", "my_project"},
805+
{"unicode replaced", "projeçt-naïve", "proje-t-na-ve"},
806+
// Note: parentheses are valid in env names per EnvironmentNameRegexp: [a-zA-Z0-9-\(\)_\.]
807+
{"parens preserved", "my(project)", "my(project)"},
808+
{"empty string", "", ""},
809+
{"all special chars", "@#$", "---"},
810+
{"long name not truncated by CleanName", strings.Repeat("a", 100), strings.Repeat("a", 100)},
811+
}
812+
813+
for _, tt := range tests {
814+
t.Run(tt.name, func(t *testing.T) {
815+
result := CleanName(tt.input)
816+
require.Equal(t, tt.expected, result)
817+
})
818+
}
819+
}

cli/azd/pkg/prompt/prompt_service.go

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/azure/azure-dev/cli/azd/pkg/auth"
2020
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
2121
"github.com/azure/azure-dev/cli/azd/pkg/config"
22+
"github.com/azure/azure-dev/cli/azd/pkg/input"
2223
"github.com/azure/azure-dev/cli/azd/pkg/output"
2324
"github.com/azure/azure-dev/cli/azd/pkg/ux"
2425
)
@@ -161,6 +162,7 @@ type PromptService interface {
161162
// PromptService provides methods for prompting the user to select various Azure resources.
162163
type promptService struct {
163164
authManager AuthManager
165+
console input.Console
164166
userConfigManager config.UserConfigManager
165167
resourceService ResourceService
166168
subscriptionManager SubscriptionManager
@@ -170,13 +172,15 @@ type promptService struct {
170172
// NewPromptService creates a new prompt service.
171173
func NewPromptService(
172174
authManager AuthManager,
175+
console input.Console,
173176
userConfigManager config.UserConfigManager,
174177
subscriptionManager SubscriptionManager,
175178
resourceService ResourceService,
176179
globalOptions *internal.GlobalCommandOptions,
177180
) PromptService {
178181
return &promptService{
179182
authManager: authManager,
183+
console: console,
180184
userConfigManager: userConfigManager,
181185
subscriptionManager: subscriptionManager,
182186
resourceService: resourceService,
@@ -221,28 +225,45 @@ func (ps *promptService) PromptSubscription(
221225

222226
// Handle --no-prompt mode
223227
if ps.globalOptions.NoPrompt {
224-
if defaultSubscriptionId == "" {
225-
return nil, fmt.Errorf(
226-
"subscription selection required but cannot prompt in --no-prompt mode. " +
227-
"Set a default subscription using 'azd config set defaults.subscription <subscription-id>'")
228-
}
229-
230-
// Load subscriptions and find the default
228+
// Load subscriptions for both default lookup and auto-selection
231229
subscriptionList, err := ps.subscriptionManager.GetSubscriptions(ctx)
232230
if err != nil {
233231
return nil, fmt.Errorf("failed to load subscriptions: %w", err)
234232
}
235233

236-
for _, subscription := range subscriptionList {
237-
if strings.EqualFold(subscription.Id, defaultSubscriptionId) {
238-
return &subscription, nil
234+
// If a default subscription is configured, use it
235+
if defaultSubscriptionId != "" {
236+
for _, subscription := range subscriptionList {
237+
if strings.EqualFold(subscription.Id, defaultSubscriptionId) {
238+
return &subscription, nil
239+
}
239240
}
241+
242+
return nil, fmt.Errorf(
243+
"default subscription '%s' not found. "+
244+
"Update your default subscription using "+
245+
"'azd config set defaults.subscription <subscription-id>'",
246+
defaultSubscriptionId)
240247
}
241248

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

248269
return PromptCustomResource(ctx, CustomResourceOptions[account.Subscription]{

0 commit comments

Comments
 (0)