Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
012e011
Add auth pre-flight validation for agents (#7234)
spboyer Mar 22, 2026
b57761d
Address review feedback: remove redundant branches, add expiresOn tests
spboyer Mar 23, 2026
72378c1
Refactor: replace auth token --check with auth status exit code (#7234)
spboyer Mar 23, 2026
ed1ea44
Remove tmp/ from tracking, add to .gitignore
spboyer Mar 23, 2026
f2ca736
Revert "Remove tmp/ from tracking, add to .gitignore"
spboyer Mar 23, 2026
f05be40
Remove tmp/ files from PR (not part of #7234)
spboyer Mar 23, 2026
5ec8461
Address review: exit non-zero in both modes, fix double output
spboyer Mar 24, 2026
96a09ee
Revert non-zero exit for unauthenticated status
spboyer Mar 24, 2026
162f31f
feat: azd init -t auto-creates project directory like git clone
spboyer Mar 24, 2026
4a4ef61
Address review: path safety, help text, efficient dir check, cd quoting
spboyer Mar 25, 2026
df45d51
Fix review: validate before mkdir, resolve local paths, reject stray arg
spboyer Mar 26, 2026
8878cc8
Fix bare error: wrap with ErrInvalidFlagCombination sentinel
spboyer Mar 31, 2026
bcd9106
Strip URL query params and fragments from derived directory names
spboyer Apr 1, 2026
71f7c79
Fix init test: check azure.yaml in template subdirectory
spboyer Apr 1, 2026
5d73bea
Re-trigger CI (Test_parseExecutableFiles passes locally)
spboyer Apr 2, 2026
6f09d78
Fix Test_parseExecutableFiles CI failure
spboyer Apr 2, 2026
28c44b1
Fix CI test failures in initializer tests
spboyer Apr 3, 2026
f2a7d5e
Re-trigger CI (ADO Linux infra failure)
spboyer Apr 3, 2026
49391c9
Re-trigger CI
spboyer Apr 4, 2026
e346ca5
Address review feedback: cleanup on failure, error handling, logging,…
spboyer Apr 9, 2026
73f63de
fix: update constructor test and apply go fix modernizations
spboyer Apr 9, 2026
2b895eb
fix: re-record functional test playback recordings
spboyer Apr 9, 2026
47ccee2
fix: update Test_CLI_Init_CanUseTemplate for auto-create directory
spboyer Apr 10, 2026
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
1 change: 1 addition & 0 deletions cli/azd/cmd/constructors_coverage3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ func Test_NewInitAction(t *testing.T) {
mockinput.NewMockConsole(),
nil, // gitCli
&initFlags{},
nil, // args
nil, // repoInitializer
nil, // templateManager
nil, // featuresManager
Expand Down
232 changes: 207 additions & 25 deletions cli/azd/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
Expand All @@ -30,6 +31,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/lazy"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
"github.com/azure/azure-dev/cli/azd/pkg/project"
Expand All @@ -53,8 +55,14 @@ func newInitFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *in

func newInitCmd() *cobra.Command {
return &cobra.Command{
Use: "init",
Use: "init [directory]",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I typed azd init [dir], it prints out ERROR: positional [directory] argument requires --template: invalid flag combination. This is user confusing. We should keep init in help message.

Suggested change
Use: "init [directory]",
Use: "init",

Short: "Initialize a new application.",
Long: `Initialize a new application.

When used with --template, a new directory is created (named after the template)
and the project is initialized inside it — similar to git clone.
Pass "." as the directory to initialize in the current directory instead.`,
Args: cobra.MaximumNArgs(1),
}
}

Expand Down Expand Up @@ -134,6 +142,7 @@ type initAction struct {
cmdRun exec.CommandRunner
gitCli *git.Cli
flags *initFlags
args []string
repoInitializer *repository.Initializer
templateManager *templates.TemplateManager
featuresManager *alpha.FeatureManager
Expand All @@ -151,6 +160,7 @@ func newInitAction(
console input.Console,
gitCli *git.Cli,
flags *initFlags,
args []string,
repoInitializer *repository.Initializer,
templateManager *templates.TemplateManager,
featuresManager *alpha.FeatureManager,
Expand All @@ -167,6 +177,7 @@ func newInitAction(
cmdRun: cmdRun,
gitCli: gitCli,
flags: flags,
args: args,
repoInitializer: repoInitializer,
templateManager: templateManager,
featuresManager: featuresManager,
Expand All @@ -178,22 +189,104 @@ func newInitAction(
}
}

func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
func (i *initAction) Run(ctx context.Context) (_ *actions.ActionResult, retErr error) {
wd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("getting cwd: %w", err)
}

azdCtx := azdcontext.NewAzdContextWithDirectory(wd)
i.lazyAzdCtx.SetValue(azdCtx)

if i.flags.templateBranch != "" && i.flags.templatePath == "" {
return nil, &internal.ErrorWithSuggestion{
Err: internal.ErrBranchRequiresTemplate,
Suggestion: "Add '--template <repo-url>' when using '--branch'.",
}
}

// Validate init-mode combinations before any filesystem side effects.
isTemplateInit := i.flags.templatePath != "" || len(i.flags.templateTags) > 0
initModeCount := 0
if isTemplateInit {
initModeCount++
}
if i.flags.fromCode {
initModeCount++
}
if i.flags.minimal {
initModeCount++
}
if initModeCount > 1 {
return nil, &internal.ErrorWithSuggestion{
Err: internal.ErrMultipleInitModes,
Suggestion: "Choose one: 'azd init --template <url>', 'azd init --from-code', or 'azd init --minimal'.",
}
}

// The positional [directory] argument is only valid with --template.
if len(i.args) > 0 && !isTemplateInit {
return nil, &internal.ErrorWithSuggestion{
Err: fmt.Errorf(
"positional [directory] argument requires --template: %w",
internal.ErrInvalidFlagCombination,
),
Suggestion: "Use 'azd init --template <url> [directory]' to initialize " +
"a template into a new directory.",
}
}

// Resolve local template paths to absolute before any chdir so that
// relative paths like ../my-template resolve against the original CWD.
if i.flags.templatePath != "" && templates.LooksLikeLocalPath(i.flags.templatePath) {
absPath, err := filepath.Abs(i.flags.templatePath)
if err == nil {
i.flags.templatePath = absPath
}
}

// When a template is specified, auto-create a project directory (like git clone).
// The user can pass a positional [directory] argument to override the folder name,
// or pass "." to use the current directory (preserving existing behavior).
createdProjectDir := ""
originalWd := wd

if isTemplateInit {
targetDir, err := i.resolveTargetDirectory(wd)
if err != nil {
return nil, err
}

if targetDir != wd {
// Check if target already exists and is non-empty
if err := i.validateTargetDirectory(ctx, targetDir); err != nil {
return nil, err
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Breaking behavioral change for existing automation

Scripts doing azd init -t foo --no-prompt -e env && azd up currently get azure.yaml in CWD. After this change, azure.yaml lands inside a foo/ subdirectory, but azd up still runs in the parent - silent failure.

This is a better UX for interactive users, but the default-on behavior is risky because we don't know what's out there depending on the current in-place behavior. CI pipelines, tutorials, blog posts, and wrapper scripts all assume init puts files in CWD.

Suggestions (pick one):

  • Gate behind --clone-layout opt-in for v1, then flip the default in a later release after deprecation messaging
  • Detect --no-prompt and default to CWD (current behavior) unless a positional arg is explicitly passed
  • At minimum, treat this as a documented breaking change in release notes and bump a minor version so azd init -t foo . becomes the migration path

if err := os.MkdirAll(targetDir, osutil.PermissionDirectory); err != nil {
return nil, fmt.Errorf("creating project directory '%s': %w",
filepath.Base(targetDir), err)
}

if err := os.Chdir(targetDir); err != nil {
return nil, fmt.Errorf("changing to project directory '%s': %w",
filepath.Base(targetDir), err)
}

wd = targetDir
createdProjectDir = targetDir

// Clean up the created directory and restore the original CWD
// if any downstream step fails, matching git clone's behavior.
defer func() {
if retErr != nil {
_ = os.Chdir(originalWd)
_ = os.RemoveAll(createdProjectDir)
}
}()
}
}

azdCtx := azdcontext.NewAzdContextWithDirectory(wd)
i.lazyAzdCtx.SetValue(azdCtx)

// ensure that git is available
if err := tools.EnsureInstalled(ctx, []tools.ExternalTool{i.gitCli}...); err != nil {
return nil, err
Expand Down Expand Up @@ -238,26 +331,11 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
}

var initTypeSelect initType = initUnknown
initTypeCount := 0
if i.flags.templatePath != "" || len(i.flags.templateTags) > 0 {
initTypeCount++
if isTemplateInit {
initTypeSelect = initAppTemplate
}
if i.flags.fromCode {
initTypeCount++
} else if i.flags.fromCode || i.flags.minimal {
initTypeSelect = initFromApp
}
if i.flags.minimal {
initTypeCount++
initTypeSelect = initFromApp // Minimal now also uses initFromApp path
}

if initTypeCount > 1 {
return nil, &internal.ErrorWithSuggestion{
Err: internal.ErrMultipleInitModes,
Suggestion: "Choose one: 'azd init --template <url>', 'azd init --from-code', or 'azd init --minimal'.",
}
}

if initTypeSelect == initUnknown {
if existingProject {
Expand All @@ -281,6 +359,21 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
output.WithLinkFormat("%s", wd),
output.WithLinkFormat("%s", "https://aka.ms/azd-third-party-code-notice"))

if createdProjectDir != "" {
// Compute a user-friendly cd path relative to where they started
cdPath, relErr := filepath.Rel(originalWd, createdProjectDir)
if relErr != nil {
cdPath = createdProjectDir // Fall back to absolute path
}
// Quote the path when it contains whitespace so the hint is copy/paste-safe
cdPathDisplay := cdPath
if strings.ContainsAny(cdPath, " \t") {
cdPathDisplay = fmt.Sprintf("%q", cdPath)
}
followUp += fmt.Sprintf("\n\nChange to the project directory:\n %s",
output.WithHighLightFormat("cd %s", cdPathDisplay))
}

if i.featuresManager.IsEnabled(agentcopilot.FeatureCopilot) {
followUp += fmt.Sprintf("\n\n%s Run %s to deploy project to the cloud.",
output.WithHintFormat("(→) NEXT STEPS:"),
Expand Down Expand Up @@ -813,13 +906,21 @@ func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontex
}

func getCmdInitHelpDescription(*cobra.Command) string {
return generateCmdHelpDescription("Initialize a new application in your current directory.",
return generateCmdHelpDescription(
"Initialize a new application. When using --template, creates a project directory automatically.",
[]string{
formatHelpNote(
fmt.Sprintf("Running %s without flags specified will prompt "+
"you to initialize using your existing code, or from a template.",
output.WithHighLightFormat("init"),
)),
formatHelpNote(
fmt.Sprintf("When using %s, a new directory is created "+
"(named after the template) and the project is initialized inside it. "+
"Pass %s as the directory to use the current directory instead.",
output.WithHighLightFormat("--template"),
output.WithHighLightFormat("."),
)),
formatHelpNote(
"To view all available sample templates, including those submitted by the azd community, visit: " +
output.WithLinkFormat("https://azure.github.io/awesome-azd") + "."),
Expand All @@ -828,11 +929,16 @@ func getCmdInitHelpDescription(*cobra.Command) string {

func getCmdInitHelpFooter(*cobra.Command) string {
return generateCmdHelpSamplesBlock(map[string]string{
"Initialize a template to your current local directory from a GitHub repo.": fmt.Sprintf("%s %s",
"Initialize a template into a new project directory.": fmt.Sprintf("%s %s",
output.WithHighLightFormat("azd init --template"),
output.WithWarningFormat("[GitHub repo URL]"),
),
"Initialize a template to your current local directory from a branch other than main.": fmt.Sprintf("%s %s %s %s",
"Initialize a template into the current directory.": fmt.Sprintf("%s %s %s",
output.WithHighLightFormat("azd init --template"),
output.WithWarningFormat("[GitHub repo URL]"),
output.WithHighLightFormat("."),
),
"Initialize a template from a branch other than main.": fmt.Sprintf("%s %s %s %s",
output.WithHighLightFormat("azd init --template"),
output.WithWarningFormat("[GitHub repo URL]"),
output.WithHighLightFormat("--branch"),
Expand Down Expand Up @@ -910,3 +1016,79 @@ type initModeRequiredErrorOptions struct {
Description string `json:"description"`
Command string `json:"command"`
}

// resolveTargetDirectory determines the target directory for template initialization.
// It returns the current working directory when "." is passed or no template is specified,
// otherwise it derives or uses the explicit directory name.
func (i *initAction) resolveTargetDirectory(wd string) (string, error) {
if len(i.args) > 0 {
dirArg := i.args[0]
if dirArg == "." {
return wd, nil
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Accepts absolute paths without validation

A user can pass azd init -t foo /some/absolute/path and this will create/chdir into an arbitrary location. If cleanup-on-failure is added later (per hemarina's comment), that could lead to os.RemoveAll on a directory outside the user's project root.

Consider rejecting absolute paths here, or validating the target is a descendant of the original CWD.

if filepath.IsAbs(dirArg) {
return dirArg, nil
}

return filepath.Join(wd, dirArg), nil
}

// No positional arg: auto-derive from template path
if i.flags.templatePath != "" {
dirName := templates.DeriveDirectoryName(i.flags.templatePath)
return filepath.Join(wd, dirName), nil
}

// Template selected via --filter tags (interactive selection) — use CWD
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] --filter interactive selection doesn't get auto-directory

When isTemplateInit is true only because of --filter tags (no --template), this returns CWD since templatePath is empty. Users who pick a template interactively via --filter won't get the new git-clone-style behavior - only --template users do.

Consider deferring directory resolution until after initializeTemplate returns, when the actual template name is known. This would also enable auto-directory for interactively selected templates.

return wd, nil
}

// validateTargetDirectory checks that the target directory is safe to use.
// If it already exists and is non-empty, it prompts the user for confirmation
// or returns an error in non-interactive mode.
func (i *initAction) validateTargetDirectory(ctx context.Context, targetDir string) error {
f, err := os.Open(targetDir)
if errors.Is(err, os.ErrNotExist) {
return nil // Directory doesn't exist yet — will be created
}
if err != nil {
return fmt.Errorf("reading directory '%s': %w", filepath.Base(targetDir), err)
}

// Read a single entry to check emptiness without loading the full listing.
names, readErr := f.Readdirnames(1)
f.Close()

if readErr != nil && !errors.Is(readErr, io.EOF) {
return fmt.Errorf("checking directory contents of '%s': %w",
filepath.Base(targetDir), readErr)
}

if len(names) == 0 {
return nil // Empty directory is fine
}

dirName := filepath.Base(targetDir)

if i.console.IsNoPromptMode() {
return fmt.Errorf(
"directory '%s' already exists and is not empty; "+
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we print out the warning before this line and remove the second check? It also asked again for confirmation for overwrite the file. Total will be three times confirmation.

Image

"use '.' to initialize in the current directory instead", dirName)
}

proceed, err := i.console.Confirm(ctx, input.ConsoleOptions{
Message: fmt.Sprintf(
"Directory '%s' already exists and is not empty. Initialize here anyway?", dirName),
DefaultValue: false,
})
if err != nil {
return fmt.Errorf("prompting for directory confirmation: %w", err)
}

if !proceed {
return errors.New("initialization cancelled")
}

return nil
}
Loading
Loading