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
80 changes: 78 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" +
" 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 {
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,61 @@ 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)
}

// 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,
) error {
escaper := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
`$`, `\$`,
"`", "\\`",
"\r", `\r`,
)

// Valid shell identifier: starts with letter or underscore, then alphanumerics/underscores
validKey := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)

keys := slices.Sorted(maps.Keys(values))
for _, key := range keys {
if !validKey.MatchString(key) {
continue
}

val := values[key]
escaped := escaper.Replace(val)

// 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",
},
{
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(),
)

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