Skip to content

Commit 6f0cbae

Browse files
spboyerCopilot
andcommitted
feat: azd init -t auto-creates project directory like git clone
When using azd init -t <template>, automatically create a project directory named after the template and initialize inside it, similar to how git clone creates a directory. Changes: - Add optional [directory] positional argument to azd init - Auto-derive folder name from template path (git clone conventions) - Create directory, os.Chdir into it, run full init pipeline inside - Pass "." to use current directory (preserves existing behavior) - Show cd hint after init so users know how to enter the project - Add DeriveDirectoryName() helper with path traversal protection - Validate target directory: prompt if non-empty, error with --no-prompt Fixes #7289 Related to #4032 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7131ac1 commit 6f0cbae

File tree

6 files changed

+466
-3
lines changed

6 files changed

+466
-3
lines changed

cli/azd/cmd/init.go

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
3131
"github.com/azure/azure-dev/cli/azd/pkg/input"
3232
"github.com/azure/azure-dev/cli/azd/pkg/lazy"
33+
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
3334
"github.com/azure/azure-dev/cli/azd/pkg/output"
3435
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
3536
"github.com/azure/azure-dev/cli/azd/pkg/project"
@@ -53,8 +54,14 @@ func newInitFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *in
5354

5455
func newInitCmd() *cobra.Command {
5556
return &cobra.Command{
56-
Use: "init",
57+
Use: "init [directory]",
5758
Short: "Initialize a new application.",
59+
Long: `Initialize a new application.
60+
61+
When used with --template, a new directory is created (named after the template)
62+
and the project is initialized inside it — similar to git clone.
63+
Pass "." as the directory to initialize in the current directory instead.`,
64+
Args: cobra.MaximumNArgs(1),
5865
}
5966
}
6067

@@ -134,6 +141,7 @@ type initAction struct {
134141
cmdRun exec.CommandRunner
135142
gitCli *git.Cli
136143
flags *initFlags
144+
args []string
137145
repoInitializer *repository.Initializer
138146
templateManager *templates.TemplateManager
139147
featuresManager *alpha.FeatureManager
@@ -151,6 +159,7 @@ func newInitAction(
151159
console input.Console,
152160
gitCli *git.Cli,
153161
flags *initFlags,
162+
args []string,
154163
repoInitializer *repository.Initializer,
155164
templateManager *templates.TemplateManager,
156165
featuresManager *alpha.FeatureManager,
@@ -167,6 +176,7 @@ func newInitAction(
167176
cmdRun: cmdRun,
168177
gitCli: gitCli,
169178
flags: flags,
179+
args: args,
170180
repoInitializer: repoInitializer,
171181
templateManager: templateManager,
172182
featuresManager: featuresManager,
@@ -184,6 +194,40 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
184194
return nil, fmt.Errorf("getting cwd: %w", err)
185195
}
186196

197+
// When a template is specified, auto-create a project directory (like git clone).
198+
// The user can pass a positional [directory] argument to override the folder name,
199+
// or pass "." to use the current directory (preserving existing behavior).
200+
createdProjectDir := ""
201+
originalWd := wd
202+
isTemplateInit := i.flags.templatePath != "" || len(i.flags.templateTags) > 0
203+
204+
if isTemplateInit {
205+
targetDir, err := i.resolveTargetDirectory(wd)
206+
if err != nil {
207+
return nil, err
208+
}
209+
210+
if targetDir != wd {
211+
// Check if target already exists and is non-empty
212+
if err := i.validateTargetDirectory(ctx, targetDir); err != nil {
213+
return nil, err
214+
}
215+
216+
if err := os.MkdirAll(targetDir, osutil.PermissionDirectory); err != nil {
217+
return nil, fmt.Errorf("creating project directory '%s': %w",
218+
filepath.Base(targetDir), err)
219+
}
220+
221+
if err := os.Chdir(targetDir); err != nil {
222+
return nil, fmt.Errorf("changing to project directory '%s': %w",
223+
filepath.Base(targetDir), err)
224+
}
225+
226+
wd = targetDir
227+
createdProjectDir = targetDir
228+
}
229+
}
230+
187231
azdCtx := azdcontext.NewAzdContextWithDirectory(wd)
188232
i.lazyAzdCtx.SetValue(azdCtx)
189233

@@ -281,6 +325,16 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) {
281325
output.WithLinkFormat("%s", wd),
282326
output.WithLinkFormat("%s", "https://aka.ms/azd-third-party-code-notice"))
283327

328+
if createdProjectDir != "" {
329+
// Compute a user-friendly cd path relative to where they started
330+
cdPath, relErr := filepath.Rel(originalWd, createdProjectDir)
331+
if relErr != nil {
332+
cdPath = createdProjectDir // Fall back to absolute path
333+
}
334+
followUp += fmt.Sprintf("\n\nChange to the project directory:\n %s",
335+
output.WithHighLightFormat("cd %s", cdPath))
336+
}
337+
284338
if i.featuresManager.IsEnabled(agentcopilot.FeatureCopilot) {
285339
followUp += fmt.Sprintf("\n\n%s Run %s to deploy project to the cloud.",
286340
output.WithHintFormat("(→) NEXT STEPS:"),
@@ -910,3 +964,70 @@ type initModeRequiredErrorOptions struct {
910964
Description string `json:"description"`
911965
Command string `json:"command"`
912966
}
967+
968+
// resolveTargetDirectory determines the target directory for template initialization.
969+
// It returns the current working directory when "." is passed or no template is specified,
970+
// otherwise it derives or uses the explicit directory name.
971+
func (i *initAction) resolveTargetDirectory(wd string) (string, error) {
972+
if len(i.args) > 0 {
973+
dirArg := i.args[0]
974+
if dirArg == "." {
975+
return wd, nil
976+
}
977+
978+
if filepath.IsAbs(dirArg) {
979+
return dirArg, nil
980+
}
981+
982+
return filepath.Join(wd, dirArg), nil
983+
}
984+
985+
// No positional arg: auto-derive from template path
986+
if i.flags.templatePath != "" {
987+
dirName := templates.DeriveDirectoryName(i.flags.templatePath)
988+
return filepath.Join(wd, dirName), nil
989+
}
990+
991+
// Template selected via --filter tags (interactive selection) — use CWD
992+
return wd, nil
993+
}
994+
995+
// validateTargetDirectory checks that the target directory is safe to use.
996+
// If it already exists and is non-empty, it prompts the user for confirmation
997+
// or returns an error in non-interactive mode.
998+
func (i *initAction) validateTargetDirectory(ctx context.Context, targetDir string) error {
999+
entries, err := os.ReadDir(targetDir)
1000+
if errors.Is(err, os.ErrNotExist) {
1001+
return nil // Directory doesn't exist yet — will be created
1002+
}
1003+
if err != nil {
1004+
return fmt.Errorf("reading directory '%s': %w", filepath.Base(targetDir), err)
1005+
}
1006+
1007+
if len(entries) == 0 {
1008+
return nil // Empty directory is fine
1009+
}
1010+
1011+
dirName := filepath.Base(targetDir)
1012+
1013+
if i.console.IsNoPromptMode() {
1014+
return fmt.Errorf(
1015+
"directory '%s' already exists and is not empty; "+
1016+
"use '.' to initialize in the current directory instead", dirName)
1017+
}
1018+
1019+
proceed, err := i.console.Confirm(ctx, input.ConsoleOptions{
1020+
Message: fmt.Sprintf(
1021+
"Directory '%s' already exists and is not empty. Initialize here anyway?", dirName),
1022+
DefaultValue: false,
1023+
})
1024+
if err != nil {
1025+
return fmt.Errorf("prompting for directory confirmation: %w", err)
1026+
}
1027+
1028+
if !proceed {
1029+
return errors.New("initialization cancelled")
1030+
}
1031+
1032+
return nil
1033+
}

cli/azd/cmd/init_test.go

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import (
2525

2626
// setupInitAction creates an initAction wired with mocks that pass git-install checks.
2727
// The working directory is changed to a temp dir so that .env loading and azdcontext work.
28-
func setupInitAction(t *testing.T, mockContext *mocks.MockContext, flags *initFlags) *initAction {
28+
func setupInitAction(
29+
t *testing.T, mockContext *mocks.MockContext, flags *initFlags, args ...string,
30+
) *initAction {
2931
t.Helper()
3032

3133
// Work in a temp directory so os.Getwd / godotenv.Overload operate in isolation.
@@ -50,6 +52,7 @@ func setupInitAction(t *testing.T, mockContext *mocks.MockContext, flags *initFl
5052
cmdRun: mockContext.CommandRunner,
5153
gitCli: gitCli,
5254
flags: flags,
55+
args: args,
5356
featuresManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()),
5457
}
5558
}
@@ -231,3 +234,195 @@ func TestInitFailFastMissingEnvNonInteractive(t *testing.T) {
231234
}
232235
})
233236
}
237+
238+
func TestInitResolveTargetDirectory(t *testing.T) {
239+
t.Run("DotArgUsesCwd", func(t *testing.T) {
240+
mockContext := mocks.NewMockContext(context.Background())
241+
flags := &initFlags{
242+
templatePath: "owner/repo",
243+
global: &internal.GlobalCommandOptions{},
244+
}
245+
action := setupInitAction(t, mockContext, flags, ".")
246+
247+
wd, err := os.Getwd()
248+
require.NoError(t, err)
249+
250+
result, err := action.resolveTargetDirectory(wd)
251+
require.NoError(t, err)
252+
require.Equal(t, wd, result)
253+
})
254+
255+
t.Run("ExplicitDirectoryUsesArg", func(t *testing.T) {
256+
mockContext := mocks.NewMockContext(context.Background())
257+
flags := &initFlags{
258+
templatePath: "owner/repo",
259+
global: &internal.GlobalCommandOptions{},
260+
}
261+
action := setupInitAction(t, mockContext, flags, "my-project")
262+
263+
wd, err := os.Getwd()
264+
require.NoError(t, err)
265+
266+
result, err := action.resolveTargetDirectory(wd)
267+
require.NoError(t, err)
268+
require.Equal(t, filepath.Join(wd, "my-project"), result)
269+
})
270+
271+
t.Run("NoArgDerivesFromTemplatePath", func(t *testing.T) {
272+
mockContext := mocks.NewMockContext(context.Background())
273+
flags := &initFlags{
274+
templatePath: "Azure-Samples/todo-nodejs-mongo",
275+
global: &internal.GlobalCommandOptions{},
276+
}
277+
action := setupInitAction(t, mockContext, flags)
278+
279+
wd, err := os.Getwd()
280+
require.NoError(t, err)
281+
282+
result, err := action.resolveTargetDirectory(wd)
283+
require.NoError(t, err)
284+
require.Equal(t, filepath.Join(wd, "todo-nodejs-mongo"), result)
285+
})
286+
287+
t.Run("NoArgWithFilterTagsUsesCwd", func(t *testing.T) {
288+
mockContext := mocks.NewMockContext(context.Background())
289+
flags := &initFlags{
290+
templateTags: []string{"python"},
291+
global: &internal.GlobalCommandOptions{},
292+
}
293+
action := setupInitAction(t, mockContext, flags)
294+
295+
wd, err := os.Getwd()
296+
require.NoError(t, err)
297+
298+
result, err := action.resolveTargetDirectory(wd)
299+
require.NoError(t, err)
300+
require.Equal(t, wd, result)
301+
})
302+
303+
t.Run("TemplateWithDotGitSuffix", func(t *testing.T) {
304+
mockContext := mocks.NewMockContext(context.Background())
305+
flags := &initFlags{
306+
templatePath: "https://github.com/Azure-Samples/todo-nodejs-mongo.git",
307+
global: &internal.GlobalCommandOptions{},
308+
}
309+
action := setupInitAction(t, mockContext, flags)
310+
311+
wd, err := os.Getwd()
312+
require.NoError(t, err)
313+
314+
result, err := action.resolveTargetDirectory(wd)
315+
require.NoError(t, err)
316+
require.Equal(t, filepath.Join(wd, "todo-nodejs-mongo"), result)
317+
})
318+
}
319+
320+
func TestInitValidateTargetDirectory(t *testing.T) {
321+
t.Run("NonExistentDirectoryIsValid", func(t *testing.T) {
322+
mockContext := mocks.NewMockContext(context.Background())
323+
flags := &initFlags{
324+
templatePath: "owner/repo",
325+
global: &internal.GlobalCommandOptions{},
326+
}
327+
action := setupInitAction(t, mockContext, flags)
328+
329+
err := action.validateTargetDirectory(
330+
*mockContext.Context, filepath.Join(t.TempDir(), "nonexistent"))
331+
require.NoError(t, err)
332+
})
333+
334+
t.Run("EmptyDirectoryIsValid", func(t *testing.T) {
335+
mockContext := mocks.NewMockContext(context.Background())
336+
flags := &initFlags{
337+
templatePath: "owner/repo",
338+
global: &internal.GlobalCommandOptions{},
339+
}
340+
action := setupInitAction(t, mockContext, flags)
341+
342+
emptyDir := t.TempDir()
343+
err := action.validateTargetDirectory(*mockContext.Context, emptyDir)
344+
require.NoError(t, err)
345+
})
346+
347+
t.Run("NonEmptyDirectoryErrorsInNoPromptMode", func(t *testing.T) {
348+
mockContext := mocks.NewMockContext(context.Background())
349+
mockContext.Console.SetNoPromptMode(true)
350+
flags := &initFlags{
351+
templatePath: "owner/repo",
352+
global: &internal.GlobalCommandOptions{NoPrompt: true},
353+
}
354+
action := setupInitAction(t, mockContext, flags)
355+
356+
nonEmptyDir := t.TempDir()
357+
require.NoError(t, os.WriteFile(
358+
filepath.Join(nonEmptyDir, "existing.txt"), []byte("content"), 0600))
359+
360+
err := action.validateTargetDirectory(*mockContext.Context, nonEmptyDir)
361+
require.Error(t, err)
362+
require.Contains(t, err.Error(), "already exists and is not empty")
363+
})
364+
}
365+
366+
func TestInitCreatesProjectDirectory(t *testing.T) {
367+
t.Run("TemplateInitCreatesDirectory", func(t *testing.T) {
368+
mockContext := mocks.NewMockContext(context.Background())
369+
mockContext.Console.SetNoPromptMode(true)
370+
flags := &initFlags{
371+
templatePath: "Azure-Samples/todo-nodejs-mongo",
372+
global: &internal.GlobalCommandOptions{NoPrompt: true},
373+
}
374+
flags.EnvironmentName = "testenv"
375+
action := setupInitAction(t, mockContext, flags)
376+
377+
wd, err := os.Getwd()
378+
require.NoError(t, err)
379+
380+
expectedDir := filepath.Join(wd, "todo-nodejs-mongo")
381+
require.NoDirExists(t, expectedDir)
382+
383+
// Run will panic or error later due to missing template mocks,
384+
// but the directory should be created before that point.
385+
_ = runActionSafe(*mockContext.Context, action)
386+
require.DirExists(t, expectedDir)
387+
})
388+
389+
t.Run("DotArgDoesNotCreateDirectory", func(t *testing.T) {
390+
mockContext := mocks.NewMockContext(context.Background())
391+
mockContext.Console.SetNoPromptMode(true)
392+
flags := &initFlags{
393+
templatePath: "Azure-Samples/todo-nodejs-mongo",
394+
global: &internal.GlobalCommandOptions{NoPrompt: true},
395+
}
396+
flags.EnvironmentName = "testenv"
397+
action := setupInitAction(t, mockContext, flags, ".")
398+
399+
wd, err := os.Getwd()
400+
require.NoError(t, err)
401+
402+
// Should NOT create a todo-nodejs-mongo subdirectory
403+
_ = runActionSafe(*mockContext.Context, action)
404+
405+
derivedDir := filepath.Join(wd, "todo-nodejs-mongo")
406+
require.NoDirExists(t, derivedDir)
407+
})
408+
409+
t.Run("ExplicitDirArgCreatesNamedDirectory", func(t *testing.T) {
410+
mockContext := mocks.NewMockContext(context.Background())
411+
mockContext.Console.SetNoPromptMode(true)
412+
flags := &initFlags{
413+
templatePath: "Azure-Samples/todo-nodejs-mongo",
414+
global: &internal.GlobalCommandOptions{NoPrompt: true},
415+
}
416+
flags.EnvironmentName = "testenv"
417+
action := setupInitAction(t, mockContext, flags, "my-custom-project")
418+
419+
wd, err := os.Getwd()
420+
require.NoError(t, err)
421+
422+
expectedDir := filepath.Join(wd, "my-custom-project")
423+
require.NoDirExists(t, expectedDir)
424+
425+
_ = runActionSafe(*mockContext.Context, action)
426+
require.DirExists(t, expectedDir)
427+
})
428+
}

0 commit comments

Comments
 (0)