Skip to content

Commit 1ddaf8e

Browse files
zimegmwbrooks
andauthored
feat: support loading a dotenv '.env' file for your app (#436)
Co-authored-by: Michael Brooks <mbrooks@slack-corp.com>
1 parent 6a761ed commit 1ddaf8e

19 files changed

Lines changed: 414 additions & 75 deletions

.claude/CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ for name, tc := range tests {
164164
}
165165
```
166166

167+
### Error Handling
168+
169+
- Wrap errors returned across package boundaries with `slackerror.Wrap(err, slackerror.ErrCode)` so they carry a structured error code
170+
- Register new error codes in `internal/slackerror/errors.go`: add a constant and an entry in `ErrorCodeMap`
171+
- Error codes are alphabetically ordered in both the constants block and `ErrorCodeMap`
172+
167173
## Version Management
168174

169175
Versions use semantic versioning with git tags (format: `v*.*.*`).

cmd/platform/deploy.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ func deployHook(ctx context.Context, clients *shared.ClientFactory) error {
181181
// so we instantiate the default here.
182182
shell := hooks.HookExecutorDefaultProtocol{
183183
IO: clients.IO,
184+
Fs: clients.Fs,
184185
}
185186
if _, err := shell.Execute(ctx, hookExecOpts); err != nil {
186187
return err

cmd/platform/deploy_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ func TestDeployCommand_DeployHook(t *testing.T) {
279279

280280
clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(clients *shared.ClientFactory) {
281281
clients.SDKConfig = sdkConfigMock
282-
clients.HookExecutor = hooks.GetHookExecutor(clientsMock.IO, sdkConfigMock)
282+
clients.HookExecutor = hooks.GetHookExecutor(clientsMock.IO, clients.Fs, sdkConfigMock)
283283
})
284284
cmd := NewDeployCommand(clients)
285285
cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil }

internal/config/dotenv.go

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,10 @@ package config
1717
import (
1818
"strings"
1919

20-
"github.com/joho/godotenv"
2120
"github.com/slackapi/slack-cli/internal/version"
22-
"github.com/spf13/afero"
2321
)
2422

25-
// GetDotEnvFileVariables collects only the variables in the .env file
26-
func (c *Config) GetDotEnvFileVariables() (map[string]string, error) {
27-
variables := map[string]string{}
28-
file, err := afero.ReadFile(c.fs, ".env")
29-
if err != nil && !c.os.IsNotExist(err) {
30-
return variables, err
31-
}
32-
return godotenv.UnmarshalBytes(file)
33-
}
34-
3523
// LoadEnvironmentVariables sets flags based on their environment variable value
36-
//
37-
// Note: Values are not loaded from the .env file. Use: `GetDotEnvFileVariables`
3824
func (c *Config) LoadEnvironmentVariables() error {
3925
// Skip when dependencies are not configured
4026
if c.os == nil {

internal/config/dotenv_test.go

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,47 +18,9 @@ import (
1818
"testing"
1919

2020
"github.com/slackapi/slack-cli/internal/slackdeps"
21-
"github.com/spf13/afero"
2221
"github.com/stretchr/testify/assert"
2322
)
2423

25-
func Test_DotEnv_GetDotEnvFileVariables(t *testing.T) {
26-
tests := map[string]struct {
27-
globalVariableName string
28-
globalVariableValue string
29-
localEnvFile string
30-
expectedValues map[string]string
31-
}{
32-
"environment file variables are read": {
33-
localEnvFile: "SLACK_VARIABLE=12\n",
34-
expectedValues: map[string]string{"SLACK_VARIABLE": "12"},
35-
},
36-
"variable casing is preserved on load": {
37-
localEnvFile: "secret_Token=Key123!\n",
38-
expectedValues: map[string]string{"secret_Token": "Key123!"},
39-
},
40-
"global environment variables are ignored": {
41-
globalVariableName: "SLACK_VARIABLE",
42-
globalVariableValue: "12",
43-
expectedValues: map[string]string{},
44-
},
45-
}
46-
for name, tc := range tests {
47-
t.Run(name, func(t *testing.T) {
48-
fs := slackdeps.NewFsMock()
49-
os := slackdeps.NewOsMock()
50-
os.AddDefaultMocks()
51-
os.Setenv(tc.globalVariableName, tc.globalVariableValue)
52-
err := afero.WriteFile(fs, ".env", []byte(tc.localEnvFile), 0600)
53-
assert.NoError(t, err)
54-
config := NewConfig(fs, os)
55-
variables, err := config.GetDotEnvFileVariables()
56-
assert.NoError(t, err)
57-
assert.Equal(t, tc.expectedValues, variables)
58-
})
59-
}
60-
}
61-
6224
func Test_DotEnv_LoadEnvironmentVariables(t *testing.T) {
6325
tableTests := map[string]struct {
6426
envName string

internal/hooks/hook_executor_default.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,20 @@ import (
2121

2222
"github.com/slackapi/slack-cli/internal/iostreams"
2323
"github.com/slackapi/slack-cli/internal/slackerror"
24+
"github.com/spf13/afero"
2425
)
2526

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

3335
// Execute processes the data received by the SDK.
3436
func (e *HookExecutorDefaultProtocol) Execute(ctx context.Context, opts HookExecOpts) (string, error) {
35-
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(opts)
37+
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(ctx, opts, e.Fs, e.IO)
3638
if err != nil {
3739
return "", err
3840
}

internal/hooks/hook_executor_default_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/slackapi/slack-cli/internal/slackcontext"
2626
"github.com/slackapi/slack-cli/internal/slackdeps"
2727
"github.com/slackapi/slack-cli/internal/slackerror"
28+
"github.com/spf13/afero"
2829
"github.com/stretchr/testify/assert"
2930
"github.com/stretchr/testify/require"
3031
)
@@ -80,6 +81,31 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) {
8081
require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `YIN=yang`)
8182
},
8283
},
84+
"dotenv vars and hook vars are loaded into the environment": {
85+
opts: HookExecOpts{
86+
Hook: HookScript{Name: "happypath", Command: "echo {}"},
87+
Env: map[string]string{
88+
"OPTS_VAR": "from_opts",
89+
},
90+
Exec: &MockExec{
91+
mockCommand: &MockCommand{
92+
MockStdout: []byte("test output"),
93+
Err: nil,
94+
},
95+
},
96+
},
97+
handler: func(t *testing.T, ctx context.Context, executor HookExecutor, opts HookExecOpts) {
98+
// Write a .env file to the mock filesystem
99+
e := executor.(*HookExecutorDefaultProtocol)
100+
_ = afero.WriteFile(e.Fs, ".env", []byte("DOTENV_VAR=from_dotenv\n"), 0600)
101+
102+
response, err := executor.Execute(ctx, opts)
103+
require.Equal(t, "test output", response)
104+
require.NoError(t, err)
105+
require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `DOTENV_VAR=from_dotenv`)
106+
require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `OPTS_VAR=from_opts`)
107+
},
108+
},
83109
"failed execution": {
84110
opts: HookExecOpts{
85111
Hook: HookScript{Command: "boom", Name: "sadpath"},
@@ -156,6 +182,7 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) {
156182
ios.AddDefaultMocks()
157183
hookExecutor := &HookExecutorDefaultProtocol{
158184
IO: ios,
185+
Fs: afero.NewMemMapFs(),
159186
}
160187
if tc.handler != nil {
161188
tc.handler(t, ctx, hookExecutor, tc.opts)

internal/hooks/hook_executor_v2.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,23 @@ import (
2525

2626
"github.com/slackapi/slack-cli/internal/iostreams"
2727
"github.com/slackapi/slack-cli/internal/slackerror"
28+
"github.com/spf13/afero"
2829
)
2930

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

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

4042
// Execute processes the data received by the SDK.
4143
func (e *HookExecutorMessageBoundaryProtocol) Execute(ctx context.Context, opts HookExecOpts) (string, error) {
42-
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(opts)
44+
cmdArgs, cmdArgVars, cmdEnvVars, err := processExecOpts(ctx, opts, e.Fs, e.IO)
4345
if err != nil {
4446
return "", err
4547
}

internal/hooks/hook_executor_v2_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/slackapi/slack-cli/internal/slackcontext"
2626
"github.com/slackapi/slack-cli/internal/slackdeps"
2727
"github.com/slackapi/slack-cli/internal/slackerror"
28+
"github.com/spf13/afero"
2829
"github.com/stretchr/testify/assert"
2930
"github.com/stretchr/testify/require"
3031
)
@@ -41,6 +42,7 @@ func mockBoundaryStringGenerator() string {
4142
func Test_Hook_Execute_V2_Protocol(t *testing.T) {
4243
tests := map[string]struct {
4344
opts HookExecOpts
45+
setup func(afero.Fs)
4446
check func(*testing.T, string, error, ExecInterface)
4547
}{
4648
"error if hook command unavailable": {
@@ -132,6 +134,29 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) {
132134
)
133135
},
134136
},
137+
"dotenv vars and hook vars are loaded into the environment": {
138+
opts: HookExecOpts{
139+
Hook: HookScript{Name: "happypath", Command: "echo {}"},
140+
Env: map[string]string{
141+
"OPTS_VAR": "from_opts",
142+
},
143+
Exec: &MockExec{
144+
mockCommand: &MockCommand{
145+
MockStdout: []byte(mockBoundaryString + `{"ok": true}` + mockBoundaryString),
146+
Err: nil,
147+
},
148+
},
149+
},
150+
setup: func(fs afero.Fs) {
151+
_ = afero.WriteFile(fs, ".env", []byte("DOTENV_VAR=from_dotenv\n"), 0600)
152+
},
153+
check: func(t *testing.T, response string, err error, mockExec ExecInterface) {
154+
require.NoError(t, err)
155+
require.Equal(t, `{"ok": true}`, response)
156+
require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `DOTENV_VAR=from_dotenv`)
157+
require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `OPTS_VAR=from_opts`)
158+
},
159+
},
135160
"fail to parse payload due to improper boundary strings": {
136161
opts: HookExecOpts{
137162
Hook: HookScript{Name: "happypath", Command: "echo {}"},
@@ -176,8 +201,13 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) {
176201
config := config.NewConfig(fs, os)
177202
ios := iostreams.NewIOStreamsMock(config, fs, os)
178203
ios.AddDefaultMocks()
204+
memFs := afero.NewMemMapFs()
179205
hookExecutor := &HookExecutorMessageBoundaryProtocol{
180206
IO: ios,
207+
Fs: memFs,
208+
}
209+
if tc.setup != nil {
210+
tc.setup(memFs)
181211
}
182212
response, err := hookExecutor.Execute(ctx, tc.opts)
183213
tc.check(t, response, err, tc.opts.Exec)

internal/hooks/hooks.go

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,34 @@ package hooks
1616

1717
import (
1818
"context"
19-
"os"
2019
"strings"
2120

2221
"github.com/slackapi/slack-cli/internal/goutils"
2322
"github.com/slackapi/slack-cli/internal/iostreams"
23+
"github.com/spf13/afero"
2424
)
2525

2626
type HookExecutor interface {
2727
Execute(ctx context.Context, opts HookExecOpts) (response string, err error)
2828
}
2929

30-
func GetHookExecutor(ios iostreams.IOStreamer, cfg SDKCLIConfig) HookExecutor {
30+
func GetHookExecutor(ios iostreams.IOStreamer, fs afero.Fs, cfg SDKCLIConfig) HookExecutor {
3131
protocol := cfg.Config.SupportedProtocols.Preferred()
3232
switch protocol {
3333
case HookProtocolV2:
3434
return &HookExecutorMessageBoundaryProtocol{
3535
IO: ios,
36+
Fs: fs,
3637
}
3738
default:
3839
return &HookExecutorDefaultProtocol{
3940
IO: ios,
41+
Fs: fs,
4042
}
4143
}
4244
}
4345

44-
func processExecOpts(opts HookExecOpts) ([]string, []string, []string, error) {
46+
func processExecOpts(ctx context.Context, opts HookExecOpts, fs afero.Fs, io iostreams.IOStreamer) ([]string, []string, []string, error) {
4547
cmdStr, err := opts.Hook.Get()
4648
if err != nil {
4749
return []string{}, []string{}, []string{}, err
@@ -53,13 +55,7 @@ func processExecOpts(opts HookExecOpts) ([]string, []string, []string, error) {
5355
var cmdArgVars = cmdArgs[1:] // omit the first item because that is the command name
5456
cmdArgVars = append(cmdArgVars, goutils.MapToStringSlice(opts.Args, "--")...)
5557

56-
// Whatever cmd.Env is set to will be the ONLY environment variables that the `cmd` will have access to when it runs.
57-
// To avoid removing any environment variables that are set in the current environment, we first set the cmd.Env to the current environment.
58-
// before adding any new environment variables.
59-
var cmdEnvVars = os.Environ()
60-
for name, value := range opts.Env {
61-
cmdEnvVars = append(cmdEnvVars, name+"="+value)
62-
}
58+
cmdEnvVars := opts.ShellEnv(ctx, fs, io)
6359

6460
return cmdArgs, cmdArgVars, cmdEnvVars, nil
6561
}

0 commit comments

Comments
 (0)