Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
191 changes: 189 additions & 2 deletions cli/azd/cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"regexp"
"slices"
"strings"
"time"
Expand Down Expand Up @@ -1263,17 +1265,48 @@ func newEnvGetValuesCmd() *cobra.Command {
return &cobra.Command{
Use: "get-values",
Short: "Get all environment values.",
Args: cobra.NoArgs,
Long: "Get all environment values.\n\n" +
"Use --export to output in shell-ready format.\n" +
"Use --shell to select the syntax " +
"(bash or pwsh, default: bash).\n\n" +
"Bash/zsh/ksh (POSIX):\n\n" +
" eval \"$(azd env get-values --export)\"\n\n" +
"PowerShell:\n\n" +
" azd env get-values --export --shell pwsh " +
"| Invoke-Expression\n\n" +
"POSIX output uses $'...' (ANSI-C) quoting " +
"for values containing newlines\n" +
"or carriage returns, " +
Comment on lines +1270 to +1279
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.

@spboyer This seems like a complicated feature we'd have to support longer term. My current concerns are:

  1. azd would need knowledge of all shell syntax rules
  2. There are other shells outside of just sh, e.g. nu, fish, that wouldn't work out-of-the-box
  3. The importing logic into shell isn't that simple either.

I would strongly recommend going down the path of azd env exec (and azd env exec -- for args support), which allows azd to passthrough environment variables to all shells, and we would eliminate all the complication.

I would imagine this being much more agent-friendly as well.

Happy to discuss further. I don't believe this fully resolves the concerns in the linked issue -- I think there are separately requirements around "filtered exports" and "automatic exports" that I can also design for if we're prioritizing fixing this issue today.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@weikanglim Really appreciate the thoughtful feedback. You raise valid concerns — maintaining shell-specific syntax rules long-term is fragile, and it doesn't cover nu/fish/etc.

azd env exec is a much cleaner approach: azd sets the environment natively and passes through to any shell without needing to know syntax rules. That eliminates the escaping complexity entirely.

I'm on board with pivoting this direction. A few questions to align on scope:

  1. Would azd env exec -- <command> be the basic form?
  2. For the "filtered exports" and "automatic exports" requirements you mentioned — happy to discuss those as follow-up features.

For now, I'll fix the existing code bugs reviewers flagged (escaping, PowerShell newlines) since they're real correctness issues, and we can decide whether to rework this PR toward azd env exec or open a new one.

Copy link
Copy Markdown
Contributor

@weikanglim weikanglim Apr 1, 2026

Choose a reason for hiding this comment

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

@spboyer Thanks for entertaining the feedback. I spent some time drafting the alternate issue for azd env exec in 7423, and I do prefer it much better as a cleaner option.

Answering the questions briefly:

  1. Basic form would be azd env exec. Extended form for flag parsing would be azd env exec --.

    For example:

    azd env exec python script.py
    azd env exec ./write-env.sh
    # for passing flags to the script, `--` is required
    azd env exec -- python script.py --port 8000 --reload

    I think agents / automation would also have an easier time with this direction, since they can pass argv directly instead of composing eval / shell-specific export syntax.

  2. I agree with the scoping decision here.

    My concern is mostly around overlapping concepts: if --export means a certain thing on env get-values, that could get confusing with future env export directions for filtered exports / automatic exports.

I opened 7423 for the env exec direction, and I'll capture remaining requirements from 4383 so we don't lose track of them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@weikanglim Ping — I'm on board with the azd env exec direction. Would love to sync on scope (basic form, filtered exports, etc.) so I can start on it. Happy to chat async or hop on a call.

"which requires bash, zsh, or ksh.",
Args: cobra.NoArgs,
}
}

type envGetValuesFlags struct {
internal.EnvFlag
global *internal.GlobalCommandOptions
export bool
shell string
}

func (eg *envGetValuesFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) {
func (eg *envGetValuesFlags) Bind(
local *pflag.FlagSet,
global *internal.GlobalCommandOptions,
) {
eg.EnvFlag.Bind(local, global)
local.BoolVar(
&eg.export,
"export",
false,
"Output in shell-ready format. "+
"Use --shell to select the shell syntax (default: bash).",
)
local.StringVar(
&eg.shell,
"shell",
"bash",
"Shell syntax for --export output: bash (POSIX) or pwsh (PowerShell).",
)
eg.global = global
}

Expand Down Expand Up @@ -1305,6 +1338,39 @@ func newEnvGetValuesAction(
}

func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, error) {
shell := strings.ToLower(eg.flags.shell)
if !eg.flags.export && shell != "bash" {
return nil, &internal.ErrorWithSuggestion{
Err: fmt.Errorf(
"--shell requires --export: %w",
internal.ErrInvalidFlagCombination,
),
Suggestion: "Use '--export --shell pwsh' together, " +
"or remove '--shell' when not using '--export'.",
}
}

if eg.flags.export && eg.formatter.Kind() != output.EnvVarsFormat {
return nil, &internal.ErrorWithSuggestion{
Err: fmt.Errorf(
"--export and --output are mutually exclusive: %w",
internal.ErrInvalidFlagCombination,
),
Suggestion: "Use '--export' without '--output', " +
"or remove '--export' to use '--output json'.",
}
}

if eg.flags.export && shell != "bash" && shell != "pwsh" {
return nil, &internal.ErrorWithSuggestion{
Err: fmt.Errorf(
"unsupported shell %q for --export: %w",
eg.flags.shell, internal.ErrInvalidFlagCombination,
),
Suggestion: "Use '--shell bash' (default) or '--shell pwsh'.",
}
}

name, err := eg.azdCtx.GetDefaultEnvironmentName()
if err != nil {
return nil, err
Expand Down Expand Up @@ -1338,9 +1404,130 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e
return nil, fmt.Errorf("ensuring environment exists: %w", err)
}

if eg.flags.export {
if shell == "pwsh" {
return nil, writePwshExportedEnv(
env.Dotenv(), eg.writer, os.Stderr,
)
}
return nil, writeExportedEnv(
env.Dotenv(), eg.writer, os.Stderr,
)
}

return nil, eg.formatter.Format(env.Dotenv(), eg.writer, nil)
}

// shellEscaper escapes characters that are special inside double-quoted
// shell strings: backslashes, double quotes, dollar signs, backticks,
// and carriage returns. Built once at package level to avoid re-allocation.
var shellEscaper = strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
`$`, `\$`,
"`", "\\`",
"\r", `\r`,
)

// ansiCEscaper escapes characters for ANSI-C $'...' quoting.
// In ANSI-C quoting, only backslash and single quote are special;
// dollar signs and backticks are literal.
var ansiCEscaper = strings.NewReplacer(
`\`, `\\`,
`'`, `\'`,
)

// validShellKey matches valid POSIX shell identifiers:
// starts with a letter or underscore, followed by alphanumerics or underscores.
var validShellKey = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)

// writeExportedEnv writes environment variables in shell-ready
// format (export KEY="VALUE") to the given writer. Values are
// double-quoted with embedded backslashes, double quotes, dollar
// signs, backticks, and carriage returns escaped. Newlines use
// ANSI-C quoting ($'...') to ensure correct round-tripping through eval.
func writeExportedEnv(
values map[string]string,
writer io.Writer,
warnWriter io.Writer,
) error {
keys := slices.Sorted(maps.Keys(values))
for _, key := range keys {
if !validShellKey.MatchString(key) {
fmt.Fprintf(
warnWriter,
"warning: skipping key %q "+
"(not a valid shell identifier)\n",
key,
)
continue
}

val := values[key]

// Use $'...' quoting for values containing newlines so \n is
// interpreted as an actual newline by the shell.
var line string
if strings.Contains(val, "\n") || strings.Contains(val, "\r") {
escaped := ansiCEscaper.Replace(val)
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
escaped = strings.ReplaceAll(escaped, "\r", `\r`)
line = fmt.Sprintf("export %s=$'%s'\n", key, escaped)
} else {
escaped := shellEscaper.Replace(val)
line = fmt.Sprintf("export %s=\"%s\"\n", key, escaped)
}

if _, err := io.WriteString(writer, line); err != nil {
return err
}
}
return nil
}

// pwshEscaper escapes characters that are special inside PowerShell
// double-quoted strings: backticks, double quotes, dollar signs,
// newlines, and carriage returns.
var pwshEscaper = strings.NewReplacer(
"`", "``",
`"`, "`\"",
`$`, "`$",
"\n", "`n",
"\r", "`r",
)

// writePwshExportedEnv writes environment variables in PowerShell format
// ($env:KEY = "VALUE") to the given writer. Values are double-quoted with
// embedded backticks, double quotes, dollar signs, newlines, and carriage
// returns escaped using the PowerShell backtick escape character.
func writePwshExportedEnv(
values map[string]string,
writer io.Writer,
warnWriter io.Writer,
) error {
keys := slices.Sorted(maps.Keys(values))
for _, key := range keys {
if !validShellKey.MatchString(key) {
fmt.Fprintf(
warnWriter,
"warning: skipping key %q "+
"(not a valid shell identifier)\n",
key,
)
continue
}

val := values[key]
escaped := pwshEscaper.Replace(val)
line := fmt.Sprintf("$env:%s = \"%s\"\n", key, escaped)

if _, err := io.WriteString(writer, line); err != nil {
return err
}
}
return nil
}

func newEnvGetValueFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envGetValueFlags {
flags := &envGetValueFlags{}
flags.Bind(cmd.Flags(), global)
Expand Down
Loading
Loading