feat: azd init -t auto-creates project directory like git clone#7290
feat: azd init -t auto-creates project directory like git clone#7290
Conversation
There was a problem hiding this comment.
Pull request overview
Updates azd init -t to behave more like git clone by defaulting template-based initialization into an auto-created project directory, with an optional positional [directory] override and updated outputs/specs/tests.
Changes:
- Add optional
[directory]positional arg toazd initand implement auto-create +chdirflow for template init. - Introduce
templates.DeriveDirectoryName()helper (with intended traversal protection) and add unit tests. - Extend auth status contract/output to include token expiry (
expiresOn) and improve login guidance for a specific auth error.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| cli/azd/pkg/templates/path.go | Adds DeriveDirectoryName() helper used to derive the auto-created project folder name. |
| cli/azd/pkg/templates/path_test.go | Adds test cases for DeriveDirectoryName() across URL/path formats and edge cases. |
| cli/azd/cmd/init.go | Adds [directory] positional arg, directory validation/creation, chdir, and a post-init cd hint. |
| cli/azd/cmd/init_test.go | Adds tests for resolving/validating/creating the target directory for template init. |
| cli/azd/cmd/testdata/TestUsage-azd-init.snap | Updates usage snapshot to include [directory]. |
| cli/azd/cmd/testdata/TestFigSpec.ts | Updates fig completion spec to include optional directory arg for init. |
| cli/azd/pkg/contracts/auth.go | Adds expiresOn field to auth status result contract. |
| cli/azd/pkg/contracts/auth_token_test.go | Adds JSON roundtrip tests for StatusResult with/without expiresOn. |
| cli/azd/cmd/auth_status.go | Populates StatusResult.ExpiresOn from acquired access token. |
| cli/azd/cmd/middleware/login_guard.go | Wraps ErrNoCurrentUser with a suggestion to run azd auth login. |
| entries, err := os.ReadDir(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) | ||
| } | ||
|
|
||
| if len(entries) == 0 { | ||
| return nil // Empty directory is fine | ||
| } |
There was a problem hiding this comment.
validateTargetDirectory uses os.ReadDir(targetDir), which reads the entire directory listing into memory just to check whether it’s empty. For large existing directories this can be unnecessarily expensive. Consider opening the directory and reading a single entry (e.g., f.ReadDir(1) / Readdirnames(1)) to determine emptiness.
| 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), | ||
| } |
There was a problem hiding this comment.
The command’s Long help now states that --template creates a new directory, but elsewhere the init help text still describes initializing in the current directory (e.g., the generated help description used by docs/snapshots). Please update the remaining help description/footer text for consistency with the new default behavior when using templates.
| • To view all available sample templates, including those submitted by the azd community, visit: https://azure.github.io/awesome-azd. | ||
|
|
||
| Usage | ||
| azd init [flags] | ||
| azd init [directory] [flags] |
There was a problem hiding this comment.
The usage snapshot still says “Initialize a new application in your current directory.” With the new behavior (template init auto-creates a subdirectory by default), this description is now misleading. Update the snapshot/help text to reflect the template directory creation behavior (while still noting that . preserves current-directory init).
| ClientID string `json:"clientId,omitempty"` | ||
|
|
||
| // When authenticated, the time at which the current access token expires. | ||
| ExpiresOn *RFC3339Time `json:"expiresOn,omitempty"` |
There was a problem hiding this comment.
StatusResult.ExpiresOn is typed as *RFC3339Time while LoginResult.ExpiresOn uses *time.Time. This can lead to inconsistent JSON timestamp formatting across auth commands. Consider aligning the types/formatting (or explicitly documenting the difference) so consumers don’t have to special-case parsing.
| ExpiresOn *RFC3339Time `json:"expiresOn,omitempty"` | |
| ExpiresOn *time.Time `json:"expiresOn,omitempty"` |
| func DeriveDirectoryName(templatePath string) string { | ||
| path := strings.TrimSpace(templatePath) | ||
| path = strings.TrimRight(path, "/") | ||
|
|
||
| // Strip .git suffix (like git clone does) | ||
| path = strings.TrimSuffix(path, ".git") | ||
|
|
||
| var name string | ||
|
|
||
| // For remote URIs, extract the last path segment from the URL | ||
| if isRemoteURI(path) { | ||
| // Handle git@host:owner/repo format | ||
| if strings.HasPrefix(path, "git@") { | ||
| if idx := strings.LastIndex(path, ":"); idx >= 0 { | ||
| path = path[idx+1:] | ||
| } | ||
| } | ||
|
|
||
| // Take the last path segment | ||
| if idx := strings.LastIndex(path, "/"); idx >= 0 { | ||
| name = path[idx+1:] | ||
| } else { | ||
| name = path | ||
| } | ||
| } else { | ||
| // For local paths and bare names, use the last path component | ||
| name = filepath.Base(path) | ||
| } | ||
|
|
||
| // Reject unsafe directory names that could cause path traversal | ||
| if name == "." || name == ".." || name == "" { | ||
| // Fall back to a sanitized version of the full path | ||
| name = strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace( | ||
| strings.TrimRight(templatePath, "/")) | ||
| } | ||
|
|
||
| return name |
There was a problem hiding this comment.
DeriveDirectoryName() can still return "." or ".." when templatePath itself is "."/".." (the fallback sanitizer doesn’t change those values). When used by azd init this can resolve to the current/parent directory (e.g., Join(wd, "..")), which contradicts the path-traversal protection intent and can lead to initializing in an unintended location. Ensure the function never returns "."/".."/"" (even after fallback sanitization) by choosing a safe default name (and consider trimming both "/" and "\" plus trimming spaces consistently).
| { | ||
| name: "BareDotDotIsSanitized", | ||
| input: "..", | ||
| expected: "..", |
There was a problem hiding this comment.
This test case currently encodes the behavior that DeriveDirectoryName("..") returns "..". If DeriveDirectoryName is meant to provide path-traversal protection for derived project directories, returning ".." is unsafe (it can resolve to the parent directory). Update this expectation once the helper is fixed to return a non-traversing directory name for "."/".." inputs.
| expected: "..", | |
| expected: "dotdot", |
| followUp += fmt.Sprintf("\n\nChange to the project directory:\n %s", | ||
| output.WithHighLightFormat("cd %s", cdPath)) |
There was a problem hiding this comment.
The post-init cd hint is emitted as cd %s without quoting. If the derived/explicit directory contains spaces (e.g., azd init -t ... "my project"), the suggested command won’t work as-is. Consider conditionally quoting/escaping cdPath (e.g., wrap in double quotes) so the hint is copy/paste-safe across common shells.
| followUp += fmt.Sprintf("\n\nChange to the project directory:\n %s", | |
| output.WithHighLightFormat("cd %s", cdPath)) | |
| // Quote the cd 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)) |
6f0cbae to
e625f6a
Compare
|
/azp run |
|
You have several pipelines (over 10) configured to build pull requests in this repository. Specify which pipelines you would like to run by using /azp run [pipelines] command. You can specify multiple pipelines using a comma separated list. |
Add --check flag to 'azd auth token' for lightweight auth validation. Agents can call 'azd auth token --check' to validate authentication state with exit code 0 (valid) or non-zero (invalid) without producing standard output. This prevents costly retry loops where agents speculatively call auth token and parse errors. Enhance 'azd auth status --output json' to include expiresOn field, giving agents machine-readable token expiry information for proactive re-authentication. Improve LoginGuardMiddleware to wrap ErrNoCurrentUser with actionable ErrorWithSuggestion guidance, while preserving original error types for cancellations and transient failures. Changes: - cmd/auth_token.go: Add --check flag with early-exit validation - cmd/auth_token_test.go: Add 3 test cases (check success/failure/not-logged-in) - cmd/auth_status.go: Populate ExpiresOn from token validation - pkg/contracts/auth.go: Add ExpiresOn field to StatusResult - cmd/middleware/login_guard.go: Wrap ErrNoCurrentUser with suggestion Fixes #7234 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove redundant 'if a.flags.check' branches in auth_token.go that duplicated the same return (Copilot review comment #2) - Add StatusResult JSON serialization tests verifying expiresOn is present when authenticated and omitted when unauthenticated (Copilot review comment #3) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of adding a --check flag to the hidden 'auth token' command, make the existing 'auth status --output json' command agent-friendly: - Exit non-zero when unauthenticated in machine-readable mode, so agents can rely on exit code without parsing output - expiresOn field already added to StatusResult in this PR - Remove --check flag and its tests (net -90 lines) Agents can now validate auth with: azd auth status --output json # exit 0 + JSON with expiresOn = valid # exit 1 + JSON with status:unauthenticated = invalid This is more discoverable than a hidden flag on a hidden command. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This reverts commit 7253f21.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per @JeffreyCA feedback: - Return auth.ErrNoCurrentUser when unauthenticated in both JSON and interactive modes (exit non-zero in all cases) - In JSON mode, format output before returning error to avoid double-print - In interactive mode, show status UX then exit non-zero Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per @vhvb1989 feedback: unauthenticated is a valid result, not a command failure. Non-zero exit should only be for unexpected errors. The expiresOn and LoginGuardMiddleware improvements remain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
e625f6a to
584d3b2
Compare
Add lessons learned from recent PR reviews (#7290, #7251, #7250, #7247, #7236, #7235, #7202, #7039) as agent instructions to prevent recurring review findings. New sections: - Error handling: ErrorWithSuggestion completeness, telemetry service attribution, scope-agnostic messages - Architecture boundaries: pkg/project target-agnostic, extension docs - Output formatting: shell-safe paths, consistent JSON contracts - Path safety: traversal validation, quoted paths in messages - Testing best practices: test actual rules, extract shared helpers, correct env vars, TypeScript patterns, efficient dir checks - CI/GitHub Actions: permissions, PATH handling, artifact downloads, prefer ADO for secrets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add lessons learned from team and Copilot reviews across PRs #7290, #7251, #7250, #7247, #7236, #7235, #7202, #7039 as agent instructions to prevent recurring review findings. New/expanded sections: - Error handling: ErrorWithSuggestion field completeness, telemetry service attribution, scope-agnostic messages, link/suggestion parity, stale data in polling loops - Architecture boundaries: pkg/project target-agnostic, extension docs separation, env var verification against source code - Output formatting: shell-safe quoted paths, consistent JSON types - Path safety: traversal validation, quoted paths in messages - Code organization: extract shared logic across scopes - Documentation standards: help text consistency, no dead references, PR description accuracy - Testing best practices: test YAML rules e2e, extract shared helpers, correct env vars (AZD_FORCE_TTY, NO_COLOR), TypeScript patterns, reasonable timeouts, cross-platform paths, test new JSON fields - CI / GitHub Actions: permissions blocks, PATH handling, cross-workflow artifacts, prefer ADO for secrets, no placeholder steps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/azp run azure-dev - cli |
|
Azure Pipelines successfully started running 1 pipeline(s). |
Summary
When using
azd init -t <template>, automatically create a project directory named after the template and initialize inside it — similar to howgit clonecreates a directory.This addresses user feedback from a getting-started study (#4032):
Fixes #7289
Changes
[directory]positional argument toazd initgit cloneconventionsos.Chdirinto it, run full init pipeline inside.as directory to use current directory (preserves existing behavior)cdhint after init so users know how to enter the projectDeriveDirectoryName()helper with path traversal protection--no-promptUsage
Testing
DeriveDirectoryName()including edge cases (traversal, .git suffix, various URL formats)Files Changed
pkg/templates/path.goDeriveDirectoryName()with traversal protectioncmd/init.gocmd/init_test.gopkg/templates/path_test.gocmd/testdata/*.snap