Skip to content

Commit 877ac34

Browse files
spboyerCopilot
andcommitted
feat: add --export flag to azd env get-values for shell sourcing
Add --export flag that outputs environment variables in shell-ready format (export KEY="VALUE" for bash/zsh). This enables easy shell integration: eval "$(azd env get-values --export)" Fixes #4384 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f8fdf47 commit 877ac34

4 files changed

Lines changed: 249 additions & 2 deletions

File tree

cli/azd/cmd/env.go

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"errors"
1010
"fmt"
1111
"io"
12+
"maps"
1213
"os"
1314
"slices"
1415
"strings"
@@ -1263,17 +1264,32 @@ func newEnvGetValuesCmd() *cobra.Command {
12631264
return &cobra.Command{
12641265
Use: "get-values",
12651266
Short: "Get all environment values.",
1266-
Args: cobra.NoArgs,
1267+
Long: "Get all environment values.\n\n" +
1268+
"Use --export to output in shell-ready format " +
1269+
"(export KEY=\"VALUE\").\n" +
1270+
"This enables shell integration:\n\n" +
1271+
" eval \"$(azd env get-values --export)\"",
1272+
Args: cobra.NoArgs,
12671273
}
12681274
}
12691275

12701276
type envGetValuesFlags struct {
12711277
internal.EnvFlag
12721278
global *internal.GlobalCommandOptions
1279+
export bool
12731280
}
12741281

1275-
func (eg *envGetValuesFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) {
1282+
func (eg *envGetValuesFlags) Bind(
1283+
local *pflag.FlagSet,
1284+
global *internal.GlobalCommandOptions,
1285+
) {
12761286
eg.EnvFlag.Bind(local, global)
1287+
local.BoolVar(
1288+
&eg.export,
1289+
"export",
1290+
false,
1291+
"Output in shell-ready format (export KEY=\"VALUE\").",
1292+
)
12771293
eg.global = global
12781294
}
12791295

@@ -1305,6 +1321,12 @@ func newEnvGetValuesAction(
13051321
}
13061322

13071323
func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, error) {
1324+
if eg.flags.export && eg.formatter.Kind() != output.EnvVarsFormat {
1325+
return nil, fmt.Errorf(
1326+
"--export and --output are mutually exclusive",
1327+
)
1328+
}
1329+
13081330
name, err := eg.azdCtx.GetDefaultEnvironmentName()
13091331
if err != nil {
13101332
return nil, err
@@ -1338,9 +1360,42 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e
13381360
return nil, fmt.Errorf("ensuring environment exists: %w", err)
13391361
}
13401362

1363+
if eg.flags.export {
1364+
return nil, writeExportedEnv(
1365+
env.Dotenv(), eg.writer,
1366+
)
1367+
}
1368+
13411369
return nil, eg.formatter.Format(env.Dotenv(), eg.writer, nil)
13421370
}
13431371

1372+
// writeExportedEnv writes environment variables in shell-ready
1373+
// format (export KEY="VALUE") to the given writer. Values are
1374+
// double-quoted with embedded backslashes, double quotes, dollar
1375+
// signs, backticks, newlines, and carriage returns escaped.
1376+
func writeExportedEnv(
1377+
values map[string]string,
1378+
writer io.Writer,
1379+
) error {
1380+
keys := slices.Sorted(maps.Keys(values))
1381+
for _, key := range keys {
1382+
val := values[key]
1383+
escaped := strings.NewReplacer(
1384+
`\`, `\\`,
1385+
`"`, `\"`,
1386+
`$`, `\$`,
1387+
"`", "\\`",
1388+
"\n", `\n`,
1389+
"\r", `\r`,
1390+
).Replace(val)
1391+
line := fmt.Sprintf("export %s=\"%s\"\n", key, escaped)
1392+
if _, err := io.WriteString(writer, line); err != nil {
1393+
return err
1394+
}
1395+
}
1396+
return nil
1397+
}
1398+
13441399
func newEnvGetValueFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envGetValueFlags {
13451400
flags := &envGetValueFlags{}
13461401
flags.Bind(cmd.Flags(), global)

cli/azd/cmd/env_get_values_test.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"bytes"
8+
"testing"
9+
10+
"github.com/azure/azure-dev/cli/azd/internal"
11+
"github.com/azure/azure-dev/cli/azd/pkg/environment"
12+
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
13+
"github.com/azure/azure-dev/cli/azd/pkg/output"
14+
"github.com/azure/azure-dev/cli/azd/test/mocks"
15+
"github.com/azure/azure-dev/cli/azd/test/mocks/mockenv"
16+
"github.com/stretchr/testify/mock"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestEnvGetValuesExport(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
envVars map[string]string
24+
export bool
25+
expected string
26+
}{
27+
{
28+
name: "export basic values",
29+
envVars: map[string]string{
30+
"FOO": "bar",
31+
"BAZ": "qux",
32+
},
33+
export: true,
34+
expected: "export AZURE_ENV_NAME=\"test\"\n" +
35+
"export BAZ=\"qux\"\n" +
36+
"export FOO=\"bar\"\n",
37+
},
38+
{
39+
name: "export values with special characters",
40+
envVars: map[string]string{
41+
"CONN": `host="localhost" pass=$ecret`,
42+
},
43+
export: true,
44+
expected: "export AZURE_ENV_NAME=\"test\"\n" +
45+
"export CONN=" +
46+
`"host=\"localhost\" pass=\$ecret"` +
47+
"\n",
48+
},
49+
{
50+
name: "export empty value",
51+
envVars: map[string]string{
52+
"EMPTY": "",
53+
},
54+
export: true,
55+
expected: "export AZURE_ENV_NAME=\"test\"\n" +
56+
"export EMPTY=\"\"\n",
57+
},
58+
{
59+
name: "export values with newlines",
60+
envVars: map[string]string{
61+
"MULTILINE": "line1\nline2\nline3",
62+
},
63+
export: true,
64+
expected: "export AZURE_ENV_NAME=\"test\"\n" +
65+
"export MULTILINE=\"line1\\nline2\\nline3\"\n",
66+
},
67+
{
68+
name: "export values with backslashes",
69+
envVars: map[string]string{
70+
"WIN_PATH": `C:\path\to\dir`,
71+
},
72+
export: true,
73+
expected: "export AZURE_ENV_NAME=\"test\"\n" +
74+
"export WIN_PATH=\"C:\\\\path\\\\to\\\\dir\"\n",
75+
},
76+
{
77+
name: "export values with backticks and command substitution",
78+
envVars: map[string]string{
79+
"DANGEROUS": "value with `backticks` and $(command)",
80+
},
81+
export: true,
82+
expected: "export AZURE_ENV_NAME=\"test\"\n" +
83+
"export DANGEROUS=\"value with \\`backticks\\` and \\$(command)\"\n",
84+
},
85+
{
86+
name: "export values with carriage returns",
87+
envVars: map[string]string{
88+
"CR_VALUE": "line1\rline2",
89+
},
90+
export: true,
91+
expected: "export AZURE_ENV_NAME=\"test\"\n" +
92+
"export CR_VALUE=\"line1\\rline2\"\n",
93+
},
94+
{
95+
name: "no export outputs dotenv format",
96+
envVars: map[string]string{
97+
"KEY": "value",
98+
},
99+
export: false,
100+
expected: "AZURE_ENV_NAME=\"test\"\n" +
101+
"KEY=\"value\"\n",
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
mockContext := mocks.NewMockContext(
108+
t.Context(),
109+
)
110+
111+
azdCtx := azdcontext.NewAzdContextWithDirectory(
112+
t.TempDir(),
113+
)
114+
err := azdCtx.SetProjectState(
115+
azdcontext.ProjectState{
116+
DefaultEnvironment: "test",
117+
},
118+
)
119+
require.NoError(t, err)
120+
121+
testEnv := environment.New("test")
122+
for k, v := range tt.envVars {
123+
testEnv.DotenvSet(k, v)
124+
}
125+
126+
envMgr := &mockenv.MockEnvManager{}
127+
envMgr.On(
128+
"Get", mock.Anything, "test",
129+
).Return(testEnv, nil)
130+
131+
var buf bytes.Buffer
132+
formatter, err := output.NewFormatter("dotenv")
133+
require.NoError(t, err)
134+
135+
action := &envGetValuesAction{
136+
azdCtx: azdCtx,
137+
console: mockContext.Console,
138+
envManager: envMgr,
139+
formatter: formatter,
140+
writer: &buf,
141+
flags: &envGetValuesFlags{
142+
global: &internal.GlobalCommandOptions{},
143+
export: tt.export,
144+
},
145+
}
146+
147+
_, err = action.Run(t.Context())
148+
require.NoError(t, err)
149+
require.Equal(t, tt.expected, buf.String())
150+
})
151+
}
152+
}
153+
154+
func TestEnvGetValuesExportOutputMutualExclusion(t *testing.T) {
155+
mockContext := mocks.NewMockContext(t.Context())
156+
157+
azdCtx := azdcontext.NewAzdContextWithDirectory(
158+
t.TempDir(),
159+
)
160+
err := azdCtx.SetProjectState(
161+
azdcontext.ProjectState{
162+
DefaultEnvironment: "test",
163+
},
164+
)
165+
require.NoError(t, err)
166+
167+
formatter, err := output.NewFormatter("json")
168+
require.NoError(t, err)
169+
170+
var buf bytes.Buffer
171+
action := &envGetValuesAction{
172+
azdCtx: azdCtx,
173+
console: mockContext.Console,
174+
formatter: formatter,
175+
writer: &buf,
176+
flags: &envGetValuesFlags{
177+
global: &internal.GlobalCommandOptions{},
178+
export: true,
179+
},
180+
}
181+
182+
_, err = action.Run(t.Context())
183+
require.Error(t, err)
184+
require.Contains(
185+
t, err.Error(), "mutually exclusive",
186+
)
187+
}

cli/azd/cmd/testdata/TestFigSpec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,10 @@ const completionSpec: Fig.Spec = {
19171917
},
19181918
],
19191919
},
1920+
{
1921+
name: ['--export'],
1922+
description: 'Output in shell-ready format (export KEY="VALUE").',
1923+
},
19201924
],
19211925
},
19221926
{

cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Usage
66

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

1011
Global Flags
1112
-C, --cwd string : Sets the current working directory.

0 commit comments

Comments
 (0)