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
1 change: 1 addition & 0 deletions cmd/platform/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ func deployHook(ctx context.Context, clients *shared.ClientFactory) error {
// so we instantiate the default here.
shell := hooks.HookExecutorDefaultProtocol{
IO: clients.IO,
Fs: clients.Fs,
Copy link
Member Author

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 fs to access .env but we don't error otherwise. AFAICT this is required for just the deploy and run commands.

}
if _, err := shell.Execute(ctx, hookExecOpts); err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion cmd/platform/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ func TestDeployCommand_DeployHook(t *testing.T) {

clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(clients *shared.ClientFactory) {
clients.SDKConfig = sdkConfigMock
clients.HookExecutor = hooks.GetHookExecutor(clientsMock.IO, sdkConfigMock)
clients.HookExecutor = hooks.GetHookExecutor(clientsMock.IO, clients.Fs, sdkConfigMock)
})
cmd := NewDeployCommand(clients)
cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil }
Expand Down
4 changes: 3 additions & 1 deletion internal/hooks/hook_executor_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@ import (

"github.com/slackapi/slack-cli/internal/iostreams"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
)

// HookExecutorDefaultProtocol uses the original protocol between the CLI and the SDK where diagnostic info
// and hook responses come in via stdout. Data outside the expected JSON payload is ignored, with the
// exception of the 'start' hook, for which it is printed.
type HookExecutorDefaultProtocol struct {
IO iostreams.IOStreamer
Fs afero.Fs
}

// Execute processes the data received by the SDK.
func (e *HookExecutorDefaultProtocol) Execute(ctx context.Context, opts HookExecOpts) (string, error) {
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(opts)
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(ctx, opts, e.Fs, e.IO)
if err != nil {
return "", err
}
Expand Down
27 changes: 27 additions & 0 deletions internal/hooks/hook_executor_default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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": {
Copy link
Member

Choose a reason for hiding this comment

The 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 (Env:) and dotenv variables (.env).

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"},
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion internal/hooks/hook_executor_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,23 @@ import (

"github.com/slackapi/slack-cli/internal/iostreams"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
)

// HookExecutorMessageBoundaryProtocol uses a protocol between the CLI and the SDK where diagnostic info
// and hook responses come in via stdout, and hook responses are wrapped in a string denoting the
// message boundary. Only one message payload can be received.
type HookExecutorMessageBoundaryProtocol struct {
IO iostreams.IOStreamer
Fs afero.Fs
}

// generateBoundary is a function for creating boundaries that can be mocked
var generateBoundary = generateMD5FromRandomString

// Execute processes the data received by the SDK.
func (e *HookExecutorMessageBoundaryProtocol) Execute(ctx context.Context, opts HookExecOpts) (string, error) {
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(opts)
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(ctx, opts, e.Fs, e.IO)
if err != nil {
return "", err
}
Expand Down
30 changes: 30 additions & 0 deletions internal/hooks/hook_executor_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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": {
Expand Down Expand Up @@ -132,6 +134,29 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) {
)
},
},
"dotenv vars are loaded": {
Copy link
Member

Choose a reason for hiding this comment

The 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 {}"},
Expand Down Expand Up @@ -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)
Expand Down
56 changes: 51 additions & 5 deletions internal/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

question: Should we move this logic to internal/config/dotenv.go to consolidate dotenv handling to one place?


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
Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The 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 .env. The warning is printed into debug, which I don't see. Then I spend a long time debugging my app, wondering why it's not working until I realize that the environment variables are never loaded.

}
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, ", "))
Copy link
Member

Choose a reason for hiding this comment

The 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
io.PrintDebug(ctx, "loaded variables from .env file: %s", strings.Join(keys, ", "))
io.PrintDebug(ctx, "Loaded variables from .env file: %s", strings.Join(keys, ", "))

}

// 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
Copy link
Member

Choose a reason for hiding this comment

The 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
}
79 changes: 78 additions & 1 deletion internal/hooks/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The 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 .env files as well, I think. Since it's a user-edited file, we're guaranteed to have some malformed files.

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
Expand Down Expand Up @@ -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)
})
}
Expand Down
Loading
Loading