Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
84 changes: 82 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,32 @@ 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 " +
"(export KEY=\"VALUE\").\n" +
"This enables shell integration:\n\n" +
Comment thread
spboyer marked this conversation as resolved.
Outdated
" eval \"$(azd env get-values --export)\"",
Args: cobra.NoArgs,
}
}

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

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 (export KEY=\"VALUE\").",
)
eg.global = global
}

Expand Down Expand Up @@ -1305,6 +1322,13 @@ func newEnvGetValuesAction(
}

func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, error) {
if eg.flags.export && eg.formatter.Kind() != output.EnvVarsFormat {
Comment thread
spboyer marked this conversation as resolved.
return nil, fmt.Errorf(
"--export and --output are mutually exclusive: %w",
internal.ErrInvalidFlagCombination,
)
}

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

if eg.flags.export {
return nil, writeExportedEnv(
env.Dotenv(), eg.writer,
)
}

return nil, eg.formatter.Format(env.Dotenv(), eg.writer, nil)
Comment thread
spboyer marked this conversation as resolved.
}

// 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`,
)

// 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(
Comment thread
spboyer marked this conversation as resolved.
values map[string]string,
writer io.Writer,
) error {
keys := slices.Sorted(maps.Keys(values))
for _, key := range keys {
if !validShellKey.MatchString(key) {
Comment thread
spboyer marked this conversation as resolved.
continue
}

Comment thread
spboyer marked this conversation as resolved.
val := values[key]
escaped := shellEscaper.Replace(val)
Comment thread
spboyer marked this conversation as resolved.
Outdated

// 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") {
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
line = fmt.Sprintf("export %s=$'%s'\n", key, strings.ReplaceAll(escaped, `'`, `\'`))
} else {
line = fmt.Sprintf("export %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
200 changes: 200 additions & 0 deletions cli/azd/cmd/env_get_values_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"bytes"
"testing"

"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/test/mocks"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockenv"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestEnvGetValuesExport(t *testing.T) {
tests := []struct {
name string
envVars map[string]string
export bool
expected string
}{
{
name: "export basic values",
envVars: map[string]string{
"FOO": "bar",
"BAZ": "qux",
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export BAZ=\"qux\"\n" +
"export FOO=\"bar\"\n",
},
{
name: "export values with special characters",
envVars: map[string]string{
"CONN": `host="localhost" pass=$ecret`,
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export CONN=" +
`"host=\"localhost\" pass=\$ecret"` +
"\n",
},
{
name: "export empty value",
envVars: map[string]string{
"EMPTY": "",
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export EMPTY=\"\"\n",
},
{
name: "export values with newlines",
envVars: map[string]string{
"MULTILINE": "line1\nline2\nline3",
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export MULTILINE=$'line1\\nline2\\nline3'\n",
},
{
name: "export values with backslashes",
envVars: map[string]string{
"WIN_PATH": `C:\path\to\dir`,
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export WIN_PATH=\"C:\\\\path\\\\to\\\\dir\"\n",
},
{
name: "export values with backticks and command substitution",
envVars: map[string]string{
"DANGEROUS": "value with `backticks` and $(command)",
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export DANGEROUS=\"value with \\`backticks\\` and \\$(command)\"\n",
},
{
name: "export values with carriage returns",
envVars: map[string]string{
"CR_VALUE": "line1\rline2",
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export CR_VALUE=\"line1\\rline2\"\n",
},
{
Comment thread
spboyer marked this conversation as resolved.
name: "no export outputs dotenv format",
envVars: map[string]string{
"KEY": "value",
},
export: false,
expected: "AZURE_ENV_NAME=\"test\"\n" +
"KEY=\"value\"\n",
},
{
name: "export skips invalid shell keys",
envVars: map[string]string{
"VALID_KEY": "ok",
"bad;key": "injected",
"has spaces": "nope",
"_UNDERSCORE": "fine",
},
export: true,
expected: "export AZURE_ENV_NAME=\"test\"\n" +
"export VALID_KEY=\"ok\"\n" +
"export _UNDERSCORE=\"fine\"\n",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockContext := mocks.NewMockContext(
t.Context(),
)
Comment thread
spboyer marked this conversation as resolved.

azdCtx := azdcontext.NewAzdContextWithDirectory(
t.TempDir(),
)
err := azdCtx.SetProjectState(
azdcontext.ProjectState{
DefaultEnvironment: "test",
},
)
require.NoError(t, err)

testEnv := environment.New("test")
for k, v := range tt.envVars {
testEnv.DotenvSet(k, v)
}

envMgr := &mockenv.MockEnvManager{}
envMgr.On(
"Get", mock.Anything, "test",
).Return(testEnv, nil)

var buf bytes.Buffer
formatter, err := output.NewFormatter("dotenv")
require.NoError(t, err)

action := &envGetValuesAction{
azdCtx: azdCtx,
console: mockContext.Console,
envManager: envMgr,
formatter: formatter,
writer: &buf,
flags: &envGetValuesFlags{
global: &internal.GlobalCommandOptions{},
export: tt.export,
},
}

_, err = action.Run(t.Context())
require.NoError(t, err)
require.Equal(t, tt.expected, buf.String())
})
}
}

func TestEnvGetValuesExportOutputMutualExclusion(t *testing.T) {
mockContext := mocks.NewMockContext(t.Context())

azdCtx := azdcontext.NewAzdContextWithDirectory(
t.TempDir(),
)
err := azdCtx.SetProjectState(
azdcontext.ProjectState{
DefaultEnvironment: "test",
},
)
require.NoError(t, err)

formatter, err := output.NewFormatter("json")
require.NoError(t, err)

var buf bytes.Buffer
action := &envGetValuesAction{
azdCtx: azdCtx,
console: mockContext.Console,
formatter: formatter,
writer: &buf,
flags: &envGetValuesFlags{
global: &internal.GlobalCommandOptions{},
export: true,
},
}

_, err = action.Run(t.Context())
require.Error(t, err)
require.Contains(
t, err.Error(), "mutually exclusive",
)
}
4 changes: 4 additions & 0 deletions cli/azd/cmd/testdata/TestFigSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,10 @@ const completionSpec: Fig.Spec = {
},
],
},
{
name: ['--export'],
description: 'Output in shell-ready format (export KEY="VALUE").',
},
],
},
{
Expand Down
1 change: 1 addition & 0 deletions cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Usage

Flags
-e, --environment string : The name of the environment to use.
--export : Output in shell-ready format (export KEY="VALUE").

Global Flags
-C, --cwd string : Sets the current working directory.
Expand Down
Loading