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
60 changes: 58 additions & 2 deletions cli/azd/cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"slices"
"strings"
Expand Down Expand Up @@ -1263,17 +1264,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 +1321,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 +1361,42 @@ 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, newlines, and carriage returns escaped.
func writeExportedEnv(
values map[string]string,
writer io.Writer,
) error {
keys := slices.Sorted(maps.Keys(values))
for _, key := range keys {
val := values[key]
escaped := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
`$`, `\$`,
"`", "\\`",
"\n", `\n`,
"\r", `\r`,
).Replace(val)
line := fmt.Sprintf("export %s=\"%s\"\n", key, escaped)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would this work for pwsh?
We might need to auto-detect the shell to know what output to use.

Also, can we / shall we use the flag --output instead of --export, so you could do: azd env get-values -o export-bash or azd env get-values --output export-pwsh ? That could save us from auto-detect and would just ask folks to pick the right output formatter

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
187 changes: 187 additions & 0 deletions cli/azd/cmd/env_get_values_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// 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",
},
}

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