-
Notifications
You must be signed in to change notification settings - Fork 288
feat: azd init -t auto-creates project directory like git clone #7290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
012e011
b57761d
72378c1
ed1ea44
f2ca736
f05be40
5ec8461
96a09ee
162f31f
4a4ef61
df45d51
8878cc8
bcd9106
71f7c79
5d73bea
6f09d78
28c44b1
f2a7d5e
49391c9
e346ca5
73f63de
2b895eb
47ccee2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import ( | |
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
|
|
@@ -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" | ||
|
|
@@ -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]", | ||
| 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), | ||
| } | ||
spboyer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -167,6 +177,7 @@ func newInitAction( | |
| cmdRun: cmdRun, | ||
| gitCli: gitCli, | ||
| flags: flags, | ||
| args: args, | ||
| repoInitializer: repoInitializer, | ||
| templateManager: templateManager, | ||
| featuresManager: featuresManager, | ||
|
|
@@ -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'.", | ||
spboyer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [High] Breaking behavioral change for existing automation Scripts doing 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):
|
||
| 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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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:"), | ||
|
|
@@ -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. "+ | ||
spboyer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "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") + "."), | ||
|
|
@@ -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"), | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Low] Accepts absolute paths without validation A user can pass 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] When Consider deferring directory resolution until after |
||
| 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; "+ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| "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 | ||
| } | ||

There was a problem hiding this comment.
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.