-
Notifications
You must be signed in to change notification settings - Fork 33
feat: load environment variables from '.env' file for hook commands #436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import ( | |
| "github.com/slackapi/slack-cli/internal/slackcontext" | ||
| "github.com/slackapi/slack-cli/internal/slackdeps" | ||
| "github.com/slackapi/slack-cli/internal/slackerror" | ||
| "github.com/spf13/afero" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
@@ -80,6 +81,31 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) { | |
| require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `YIN=yang`) | ||
| }, | ||
| }, | ||
| "dotenv vars are loaded": { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Very nice test! With my head in the weeds of how this all works, I appreciate that you're testing both session environment variables ( My concern is that our future selves (or teammates) but not pick up on this nuance. Perhaps a comment would help explain that we're testing both sources of environment variables? |
||
| opts: HookExecOpts{ | ||
| Hook: HookScript{Name: "happypath", Command: "echo {}"}, | ||
| Env: map[string]string{ | ||
| "OPTS_VAR": "from_opts", | ||
| }, | ||
| Exec: &MockExec{ | ||
| mockCommand: &MockCommand{ | ||
| MockStdout: []byte("test output"), | ||
| Err: nil, | ||
| }, | ||
| }, | ||
| }, | ||
| handler: func(t *testing.T, ctx context.Context, executor HookExecutor, opts HookExecOpts) { | ||
| // Write a .env file to the mock filesystem | ||
| e := executor.(*HookExecutorDefaultProtocol) | ||
| _ = afero.WriteFile(e.Fs, ".env", []byte("DOTENV_VAR=from_dotenv\n"), 0644) | ||
|
|
||
| response, err := executor.Execute(ctx, opts) | ||
| require.Equal(t, "test output", response) | ||
| require.NoError(t, err) | ||
| require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `DOTENV_VAR=from_dotenv`) | ||
| require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `OPTS_VAR=from_opts`) | ||
| }, | ||
| }, | ||
| "failed execution": { | ||
| opts: HookExecOpts{ | ||
| Hook: HookScript{Command: "boom", Name: "sadpath"}, | ||
|
|
@@ -156,6 +182,7 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) { | |
| ios.AddDefaultMocks() | ||
| hookExecutor := &HookExecutorDefaultProtocol{ | ||
| IO: ios, | ||
| Fs: afero.NewMemMapFs(), | ||
| } | ||
| if tc.handler != nil { | ||
| tc.handler(t, ctx, hookExecutor, tc.opts) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import ( | |
| "github.com/slackapi/slack-cli/internal/slackcontext" | ||
| "github.com/slackapi/slack-cli/internal/slackdeps" | ||
| "github.com/slackapi/slack-cli/internal/slackerror" | ||
| "github.com/spf13/afero" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
@@ -41,6 +42,7 @@ func mockBoundaryStringGenerator() string { | |
| func Test_Hook_Execute_V2_Protocol(t *testing.T) { | ||
| tests := map[string]struct { | ||
| opts HookExecOpts | ||
| setup func(afero.Fs) | ||
| check func(*testing.T, string, error, ExecInterface) | ||
| }{ | ||
| "error if hook command unavailable": { | ||
|
|
@@ -132,6 +134,29 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) { | |
| ) | ||
| }, | ||
| }, | ||
| "dotenv vars are loaded": { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: If we decide to add a comment to the table tests for the default hook executor, I suppose we should do it here as well. |
||
| opts: HookExecOpts{ | ||
| Hook: HookScript{Name: "happypath", Command: "echo {}"}, | ||
| Env: map[string]string{ | ||
| "OPTS_VAR": "from_opts", | ||
| }, | ||
| Exec: &MockExec{ | ||
| mockCommand: &MockCommand{ | ||
| MockStdout: []byte(mockBoundaryString + `{"ok": true}` + mockBoundaryString), | ||
| Err: nil, | ||
| }, | ||
| }, | ||
| }, | ||
| setup: func(fs afero.Fs) { | ||
| _ = afero.WriteFile(fs, ".env", []byte("DOTENV_VAR=from_dotenv\n"), 0644) | ||
| }, | ||
| check: func(t *testing.T, response string, err error, mockExec ExecInterface) { | ||
| require.NoError(t, err) | ||
| require.Equal(t, `{"ok": true}`, response) | ||
| require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `DOTENV_VAR=from_dotenv`) | ||
| require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `OPTS_VAR=from_opts`) | ||
| }, | ||
| }, | ||
| "fail to parse payload due to improper boundary strings": { | ||
| opts: HookExecOpts{ | ||
| Hook: HookScript{Name: "happypath", Command: "echo {}"}, | ||
|
|
@@ -176,8 +201,13 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) { | |
| config := config.NewConfig(fs, os) | ||
| ios := iostreams.NewIOStreamsMock(config, fs, os) | ||
| ios.AddDefaultMocks() | ||
| memFs := afero.NewMemMapFs() | ||
| hookExecutor := &HookExecutorMessageBoundaryProtocol{ | ||
| IO: ios, | ||
| Fs: memFs, | ||
| } | ||
| if tc.setup != nil { | ||
| tc.setup(memFs) | ||
| } | ||
| response, err := hookExecutor.Execute(ctx, tc.opts) | ||
| tc.check(t, response, err, tc.opts.Exec) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -19,29 +19,49 @@ import ( | |||||
| "os" | ||||||
| "strings" | ||||||
|
|
||||||
| "github.com/joho/godotenv" | ||||||
| "github.com/slackapi/slack-cli/internal/goutils" | ||||||
| "github.com/slackapi/slack-cli/internal/iostreams" | ||||||
| "github.com/spf13/afero" | ||||||
| ) | ||||||
|
|
||||||
| type HookExecutor interface { | ||||||
| Execute(ctx context.Context, opts HookExecOpts) (response string, err error) | ||||||
| } | ||||||
|
|
||||||
| func GetHookExecutor(ios iostreams.IOStreamer, cfg SDKCLIConfig) HookExecutor { | ||||||
| // LoadDotEnv reads and parses a .env file from the working directory using the | ||||||
| // provided filesystem. It returns nil if the file does not exist. | ||||||
| func LoadDotEnv(fs afero.Fs) (map[string]string, error) { | ||||||
| if fs == nil { | ||||||
| return nil, nil | ||||||
| } | ||||||
| file, err := afero.ReadFile(fs, ".env") | ||||||
| if err != nil { | ||||||
| if os.IsNotExist(err) { | ||||||
| return nil, nil | ||||||
| } | ||||||
| return nil, err | ||||||
| } | ||||||
| return godotenv.UnmarshalBytes(file) | ||||||
| } | ||||||
|
Comment on lines
+32
to
+46
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: Should we move this logic to |
||||||
|
|
||||||
| func GetHookExecutor(ios iostreams.IOStreamer, fs afero.Fs, cfg SDKCLIConfig) HookExecutor { | ||||||
| protocol := cfg.Config.SupportedProtocols.Preferred() | ||||||
| switch protocol { | ||||||
| case HookProtocolV2: | ||||||
| return &HookExecutorMessageBoundaryProtocol{ | ||||||
| IO: ios, | ||||||
| Fs: fs, | ||||||
| } | ||||||
| default: | ||||||
| return &HookExecutorDefaultProtocol{ | ||||||
| IO: ios, | ||||||
| Fs: fs, | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func processExecOpts(opts HookExecOpts) ([]string, []string, []string, error) { | ||||||
| func processExecOpts(ctx context.Context, opts HookExecOpts, fs afero.Fs, io iostreams.IOStreamer) ([]string, []string, []string, error) { | ||||||
| cmdStr, err := opts.Hook.Get() | ||||||
| if err != nil { | ||||||
| return []string{}, []string{}, []string{}, err | ||||||
|
|
@@ -53,13 +73,39 @@ func processExecOpts(opts HookExecOpts) ([]string, []string, []string, error) { | |||||
| var cmdArgVars = cmdArgs[1:] // omit the first item because that is the command name | ||||||
| cmdArgVars = append(cmdArgVars, goutils.MapToStringSlice(opts.Args, "--")...) | ||||||
|
|
||||||
| // Load .env file variables | ||||||
| dotEnv, err := LoadDotEnv(fs) | ||||||
| if err != nil { | ||||||
| io.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: Should we be more noisy about this warning? The scenario that I think about is that I have a malformed |
||||||
| } | ||||||
| if len(dotEnv) > 0 { | ||||||
| keys := make([]string, 0, len(dotEnv)) | ||||||
| for k := range dotEnv { | ||||||
| keys = append(keys, k) | ||||||
| } | ||||||
| io.PrintDebug(ctx, "loaded variables from .env file: %s", strings.Join(keys, ", ")) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: We use capital "Loaded" in localserver.go. Perhaps capitalize this as well?
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| // Whatever cmd.Env is set to will be the ONLY environment variables that the `cmd` will have access to when it runs. | ||||||
| // To avoid removing any environment variables that are set in the current environment, we first set the cmd.Env to the current environment. | ||||||
| // before adding any new environment variables. | ||||||
| var cmdEnvVars = os.Environ() | ||||||
| // | ||||||
| // Order of precedence from lowest to highest: | ||||||
| // 1. Provided "opts.Env" variables | ||||||
| // 2. Saved ".env" file | ||||||
| // 3. Existing shell environment | ||||||
| // | ||||||
| // > Each entry is of the form "key=value". | ||||||
| // > ... | ||||||
| // > If Env contains duplicate environment keys, only the last value in the slice for each duplicate key is used. | ||||||
| // | ||||||
| // https://pkg.go.dev/os/exec#Cmd.Env | ||||||
|
Comment on lines
+91
to
+100
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: ❤️ 🖊️ Love the detailed comment for future readers! |
||||||
| var cmdEnvVars []string | ||||||
| for name, value := range opts.Env { | ||||||
| cmdEnvVars = append(cmdEnvVars, name+"="+value) | ||||||
| } | ||||||
| for k, v := range dotEnv { | ||||||
| cmdEnvVars = append(cmdEnvVars, k+"="+v) | ||||||
| } | ||||||
| cmdEnvVars = append(cmdEnvVars, os.Environ()...) | ||||||
|
|
||||||
| return cmdArgs, cmdArgVars, cmdEnvVars, nil | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,9 +20,86 @@ import ( | |
| "github.com/slackapi/slack-cli/internal/config" | ||
| "github.com/slackapi/slack-cli/internal/iostreams" | ||
| "github.com/slackapi/slack-cli/internal/slackdeps" | ||
| "github.com/spf13/afero" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func Test_Hooks_LoadDotEnv(t *testing.T) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: All of these tests are great, but we should add one for malformed |
||
| tests := map[string]struct { | ||
| fs afero.Fs | ||
| dotenv string | ||
| writeDotenv bool | ||
| expected map[string]string | ||
| expectErr bool | ||
| }{ | ||
| "returns nil when fs is nil": { | ||
| fs: nil, | ||
| expected: nil, | ||
| }, | ||
| "returns nil when .env file does not exist": { | ||
| fs: afero.NewMemMapFs(), | ||
| expected: nil, | ||
| }, | ||
| "returns empty map for empty .env file": { | ||
| fs: afero.NewMemMapFs(), | ||
| dotenv: "", | ||
| writeDotenv: true, | ||
| expected: map[string]string{}, | ||
| }, | ||
| "parses single variable": { | ||
| fs: afero.NewMemMapFs(), | ||
| dotenv: "FOO=bar\n", | ||
| writeDotenv: true, | ||
| expected: map[string]string{"FOO": "bar"}, | ||
| }, | ||
| "parses multiple variables": { | ||
| fs: afero.NewMemMapFs(), | ||
| dotenv: "FOO=bar\nBAZ=qux\n", | ||
| writeDotenv: true, | ||
| expected: map[string]string{"FOO": "bar", "BAZ": "qux"}, | ||
| }, | ||
| "parses quoted values": { | ||
| fs: afero.NewMemMapFs(), | ||
| dotenv: `TOKEN="my secret token"` + "\n", | ||
| writeDotenv: true, | ||
| expected: map[string]string{"TOKEN": "my secret token"}, | ||
| }, | ||
| "skips comment lines": { | ||
| fs: afero.NewMemMapFs(), | ||
| dotenv: "# this is a comment\nFOO=bar\n", | ||
| writeDotenv: true, | ||
| expected: map[string]string{"FOO": "bar"}, | ||
| }, | ||
| "handles values with equals signs": { | ||
| fs: afero.NewMemMapFs(), | ||
| dotenv: "URL=https://example.com?foo=bar&baz=qux\n", | ||
| writeDotenv: true, | ||
| expected: map[string]string{"URL": "https://example.com?foo=bar&baz=qux"}, | ||
| }, | ||
| "handles empty values": { | ||
| fs: afero.NewMemMapFs(), | ||
| dotenv: "EMPTY=\n", | ||
| writeDotenv: true, | ||
| expected: map[string]string{"EMPTY": ""}, | ||
| }, | ||
| } | ||
| for name, tc := range tests { | ||
| t.Run(name, func(t *testing.T) { | ||
| if tc.writeDotenv && tc.fs != nil { | ||
| _ = afero.WriteFile(tc.fs, ".env", []byte(tc.dotenv), 0644) | ||
| } | ||
| result, err := LoadDotEnv(tc.fs) | ||
| if tc.expectErr { | ||
| assert.Error(t, err) | ||
| } else { | ||
| assert.NoError(t, err) | ||
| } | ||
| assert.Equal(t, tc.expected, result) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func Test_Hooks_GetHookExecutor(t *testing.T) { | ||
| tests := map[string]struct { | ||
| protocolVersions ProtocolVersions | ||
|
|
@@ -54,7 +131,7 @@ func Test_Hooks_GetHookExecutor(t *testing.T) { | |
| io := iostreams.NewIOStreamsMock(config, fs, os) | ||
| sdkConfig := NewSDKConfigMock() | ||
| sdkConfig.Config.SupportedProtocols = tc.protocolVersions | ||
| hookExecutor := GetHookExecutor(io, sdkConfig) | ||
| hookExecutor := GetHookExecutor(io, fs, sdkConfig) | ||
| require.IsType(t, tc.expectedType, hookExecutor) | ||
| }) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔭 note: Standalone protocol setups must define
fsto access.envbut we don't error otherwise. AFAICT this is required for just thedeployandruncommands.