From c4382676b6e5c90a9a642f0a167c3f008b7ac564 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 24 Mar 2026 14:47:21 -0700 Subject: [PATCH 1/9] feat: load environment variables from '.env' file for hook commands --- cmd/platform/deploy.go | 1 + cmd/platform/deploy_test.go | 2 +- internal/hooks/hook_executor_default.go | 4 +- internal/hooks/hook_executor_default_test.go | 27 +++++++ internal/hooks/hook_executor_v2.go | 4 +- internal/hooks/hook_executor_v2_test.go | 30 ++++++++ internal/hooks/hooks.go | 56 ++++++++++++-- internal/hooks/hooks_test.go | 79 +++++++++++++++++++- internal/pkg/platform/localserver.go | 36 +++++++-- internal/shared/clients.go | 4 +- 10 files changed, 228 insertions(+), 15 deletions(-) diff --git a/cmd/platform/deploy.go b/cmd/platform/deploy.go index 18ccd363..88eed572 100644 --- a/cmd/platform/deploy.go +++ b/cmd/platform/deploy.go @@ -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, } if _, err := shell.Execute(ctx, hookExecOpts); err != nil { return err diff --git a/cmd/platform/deploy_test.go b/cmd/platform/deploy_test.go index f5cabbc0..00a656e0 100644 --- a/cmd/platform/deploy_test.go +++ b/cmd/platform/deploy_test.go @@ -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 } diff --git a/internal/hooks/hook_executor_default.go b/internal/hooks/hook_executor_default.go index eb9ab2b6..c033e19f 100644 --- a/internal/hooks/hook_executor_default.go +++ b/internal/hooks/hook_executor_default.go @@ -21,6 +21,7 @@ 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 @@ -28,11 +29,12 @@ import ( // 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 } diff --git a/internal/hooks/hook_executor_default_test.go b/internal/hooks/hook_executor_default_test.go index 9556287d..1c9748f8 100644 --- a/internal/hooks/hook_executor_default_test.go +++ b/internal/hooks/hook_executor_default_test.go @@ -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": { + 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) diff --git a/internal/hooks/hook_executor_v2.go b/internal/hooks/hook_executor_v2.go index 96b146f5..e9ad4613 100644 --- a/internal/hooks/hook_executor_v2.go +++ b/internal/hooks/hook_executor_v2.go @@ -25,6 +25,7 @@ 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 @@ -32,6 +33,7 @@ import ( // 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 @@ -39,7 +41,7 @@ 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 } diff --git a/internal/hooks/hook_executor_v2_test.go b/internal/hooks/hook_executor_v2_test.go index 8b83db94..acfe766c 100644 --- a/internal/hooks/hook_executor_v2_test.go +++ b/internal/hooks/hook_executor_v2_test.go @@ -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": { + 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) diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 49ebf8a6..841d07ea 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -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) +} + +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) + } + 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, ", ")) + } + // 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 + 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 } diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index d28c223c..4ac4b63e 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -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) { + 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) }) } diff --git a/internal/pkg/platform/localserver.go b/internal/pkg/platform/localserver.go index ca038042..5324aaef 100644 --- a/internal/pkg/platform/localserver.go +++ b/internal/pkg/platform/localserver.go @@ -308,13 +308,39 @@ func (r *LocalServer) StartDelegate(ctx context.Context) error { cmdArgs := strings.Fields(cmdStr) var cmdArgVars = cmdArgs[1:] // omit the first item because that is the command name + // Load .env file variables + dotEnv, err := hooks.LoadDotEnv(r.clients.Fs) + if err != nil { + r.clients.IO.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) + } + if len(dotEnv) > 0 { + keys := make([]string, 0, len(dotEnv)) + for k := range dotEnv { + keys = append(keys, k) + } + r.clients.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() - for name, value := range sdkManagedConnectionStartHookOpts.Env { - cmdEnvVars = append(cmdEnvVars, name+"="+value) + // + // 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 + var cmdEnvVars []string + for k, v := range sdkManagedConnectionStartHookOpts.Env { + cmdEnvVars = append(cmdEnvVars, k+"="+v) + } + for k, v := range dotEnv { + cmdEnvVars = append(cmdEnvVars, k+"="+v) } + cmdEnvVars = append(cmdEnvVars, os.Environ()...) cmd := sdkManagedConnectionStartHookOpts.Exec.Command(cmdEnvVars, os.Stdout, os.Stderr, nil, cmdArgs[0], cmdArgVars...) // Store command reference for lifecycle management diff --git a/internal/shared/clients.go b/internal/shared/clients.go index e61b5ab9..ab7a0649 100644 --- a/internal/shared/clients.go +++ b/internal/shared/clients.go @@ -84,6 +84,7 @@ func NewClientFactory(options ...func(*ClientFactory)) *ClientFactory { clients.IO = iostreams.NewIOStreams(clients.Config, clients.Fs, clients.Os) clients.HookExecutor = &hooks.HookExecutorDefaultProtocol{ IO: clients.IO, + Fs: clients.Fs, } clients.EventTracker = tracking.NewEventTracker() clients.API = clients.defaultAPIFunc @@ -237,7 +238,7 @@ func (c *ClientFactory) InitSDKConfig(ctx context.Context, dirPath string) error // TODO: this is a side-effect-y way of signaling to the rest of the codebase "we are in an app project directory now" c.SDKConfig.WorkingDirectory = dirPath - c.HookExecutor = hooks.GetHookExecutor(c.IO, c.SDKConfig) + c.HookExecutor = hooks.GetHookExecutor(c.IO, c.Fs, c.SDKConfig) return err } @@ -277,6 +278,7 @@ func (c *ClientFactory) InitSDKConfigFromJSON(ctx context.Context, configFileByt } defaultExecutor := hooks.HookExecutorDefaultProtocol{ IO: c.IO, + Fs: c.Fs, } if SDKHooksResponse, err = defaultExecutor.Execute(ctx, hookExecOpts); err != nil { return err From 72828ecd29897241bfcdd07be0aae4a5ea1a30e8 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 09:58:59 -0700 Subject: [PATCH 2/9] test: make clear the purpose of env var hook tests --- internal/hooks/hook_executor_default_test.go | 2 +- internal/hooks/hook_executor_v2_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/hooks/hook_executor_default_test.go b/internal/hooks/hook_executor_default_test.go index 1c9748f8..985fae34 100644 --- a/internal/hooks/hook_executor_default_test.go +++ b/internal/hooks/hook_executor_default_test.go @@ -81,7 +81,7 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) { require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `YIN=yang`) }, }, - "dotenv vars are loaded": { + "dotenv vars and hook vars are loaded into the environment": { opts: HookExecOpts{ Hook: HookScript{Name: "happypath", Command: "echo {}"}, Env: map[string]string{ diff --git a/internal/hooks/hook_executor_v2_test.go b/internal/hooks/hook_executor_v2_test.go index acfe766c..2a51c2ad 100644 --- a/internal/hooks/hook_executor_v2_test.go +++ b/internal/hooks/hook_executor_v2_test.go @@ -134,7 +134,7 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) { ) }, }, - "dotenv vars are loaded": { + "dotenv vars and hook vars are loaded into the environment": { opts: HookExecOpts{ Hook: HookScript{Name: "happypath", Command: "echo {}"}, Env: map[string]string{ From f63d0ffd4bef49dce33b6896b18baee81a05220d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 14:34:23 -0700 Subject: [PATCH 3/9] refactor: use a slackdotenv package for reading variables from .env file --- internal/config/dotenv.go | 14 ---- internal/config/dotenv_test.go | 38 ---------- internal/hooks/hooks.go | 20 +---- internal/hooks/hooks_test.go | 77 ------------------- internal/pkg/platform/localserver.go | 3 +- internal/pkg/platform/run.go | 6 +- internal/slackdotenv/slackdotenv.go | 39 ++++++++++ internal/slackdotenv/slackdotenv_test.go | 97 ++++++++++++++++++++++++ 8 files changed, 145 insertions(+), 149 deletions(-) create mode 100644 internal/slackdotenv/slackdotenv.go create mode 100644 internal/slackdotenv/slackdotenv_test.go diff --git a/internal/config/dotenv.go b/internal/config/dotenv.go index 6a23333c..9ae6333a 100644 --- a/internal/config/dotenv.go +++ b/internal/config/dotenv.go @@ -17,24 +17,10 @@ package config import ( "strings" - "github.com/joho/godotenv" "github.com/slackapi/slack-cli/internal/version" - "github.com/spf13/afero" ) -// GetDotEnvFileVariables collects only the variables in the .env file -func (c *Config) GetDotEnvFileVariables() (map[string]string, error) { - variables := map[string]string{} - file, err := afero.ReadFile(c.fs, ".env") - if err != nil && !c.os.IsNotExist(err) { - return variables, err - } - return godotenv.UnmarshalBytes(file) -} - // LoadEnvironmentVariables sets flags based on their environment variable value -// -// Note: Values are not loaded from the .env file. Use: `GetDotEnvFileVariables` func (c *Config) LoadEnvironmentVariables() error { // Skip when dependencies are not configured if c.os == nil { diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go index 0b01f193..77d1773b 100644 --- a/internal/config/dotenv_test.go +++ b/internal/config/dotenv_test.go @@ -18,47 +18,9 @@ import ( "testing" "github.com/slackapi/slack-cli/internal/slackdeps" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" ) -func Test_DotEnv_GetDotEnvFileVariables(t *testing.T) { - tests := map[string]struct { - globalVariableName string - globalVariableValue string - localEnvFile string - expectedValues map[string]string - }{ - "environment file variables are read": { - localEnvFile: "SLACK_VARIABLE=12\n", - expectedValues: map[string]string{"SLACK_VARIABLE": "12"}, - }, - "variable casing is preserved on load": { - localEnvFile: "secret_Token=Key123!\n", - expectedValues: map[string]string{"secret_Token": "Key123!"}, - }, - "global environment variables are ignored": { - globalVariableName: "SLACK_VARIABLE", - globalVariableValue: "12", - expectedValues: map[string]string{}, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - fs := slackdeps.NewFsMock() - os := slackdeps.NewOsMock() - os.AddDefaultMocks() - os.Setenv(tc.globalVariableName, tc.globalVariableValue) - err := afero.WriteFile(fs, ".env", []byte(tc.localEnvFile), 0600) - assert.NoError(t, err) - config := NewConfig(fs, os) - variables, err := config.GetDotEnvFileVariables() - assert.NoError(t, err) - assert.Equal(t, tc.expectedValues, variables) - }) - } -} - func Test_DotEnv_LoadEnvironmentVariables(t *testing.T) { tableTests := map[string]struct { envName string diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 841d07ea..27b3c085 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -19,9 +19,9 @@ import ( "os" "strings" - "github.com/joho/godotenv" "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/slackdotenv" "github.com/spf13/afero" ) @@ -29,22 +29,6 @@ type HookExecutor interface { Execute(ctx context.Context, opts HookExecOpts) (response string, err error) } -// 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) -} - func GetHookExecutor(ios iostreams.IOStreamer, fs afero.Fs, cfg SDKCLIConfig) HookExecutor { protocol := cfg.Config.SupportedProtocols.Preferred() switch protocol { @@ -74,7 +58,7 @@ func processExecOpts(ctx context.Context, opts HookExecOpts, fs afero.Fs, io ios cmdArgVars = append(cmdArgVars, goutils.MapToStringSlice(opts.Args, "--")...) // Load .env file variables - dotEnv, err := LoadDotEnv(fs) + dotEnv, err := slackdotenv.Read(fs) if err != nil { io.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) } diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index 4ac4b63e..7b82dcb5 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -20,86 +20,9 @@ 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) { - 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 diff --git a/internal/pkg/platform/localserver.go b/internal/pkg/platform/localserver.go index 5324aaef..be03bca5 100644 --- a/internal/pkg/platform/localserver.go +++ b/internal/pkg/platform/localserver.go @@ -35,6 +35,7 @@ import ( "github.com/slackapi/slack-cli/internal/pkg/apps" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackdotenv" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" @@ -309,7 +310,7 @@ func (r *LocalServer) StartDelegate(ctx context.Context) error { var cmdArgVars = cmdArgs[1:] // omit the first item because that is the command name // Load .env file variables - dotEnv, err := hooks.LoadDotEnv(r.clients.Fs) + dotEnv, err := slackdotenv.Read(r.clients.Fs) if err != nil { r.clients.IO.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) } diff --git a/internal/pkg/platform/run.go b/internal/pkg/platform/run.go index 78380642..cda03117 100644 --- a/internal/pkg/platform/run.go +++ b/internal/pkg/platform/run.go @@ -26,6 +26,7 @@ import ( "github.com/slackapi/slack-cli/internal/pkg/apps" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackdotenv" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" @@ -102,11 +103,14 @@ func Run(ctx context.Context, clients *shared.ClientFactory, runArgs RunArgs) (t } // Gather environment variables from an environment file - variables, err := clients.Config.GetDotEnvFileVariables() + variables, err := slackdotenv.Read(clients.Fs) if err != nil { return "", slackerror.Wrap(err, slackerror.ErrLocalAppRun). WithMessage("Failed to read the local .env file") } + if variables == nil { + variables = map[string]string{} + } // Set SLACK_API_URL to the resolved host value found in the environment if value, ok := variables["SLACK_API_URL"]; ok { diff --git a/internal/slackdotenv/slackdotenv.go b/internal/slackdotenv/slackdotenv.go new file mode 100644 index 00000000..ed952aa7 --- /dev/null +++ b/internal/slackdotenv/slackdotenv.go @@ -0,0 +1,39 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slackdotenv + +import ( + "os" + + "github.com/joho/godotenv" + "github.com/spf13/afero" +) + +// Read parses a .env file from the working directory using the provided +// filesystem. It returns nil if the filesystem is nil or the file does not +// exist. +func Read(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) +} diff --git a/internal/slackdotenv/slackdotenv_test.go b/internal/slackdotenv/slackdotenv_test.go new file mode 100644 index 00000000..8c8c325d --- /dev/null +++ b/internal/slackdotenv/slackdotenv_test.go @@ -0,0 +1,97 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slackdotenv + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func Test_Read(t *testing.T) { + 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 := Read(tc.fs) + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.expected, result) + }) + } +} From 4b29a23d7b4099695b962e3d7abea392072fbecf Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 14:36:04 -0700 Subject: [PATCH 4/9] docs: godoc slackdotenv --- internal/slackdotenv/slackdotenv.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/slackdotenv/slackdotenv.go b/internal/slackdotenv/slackdotenv.go index ed952aa7..af562153 100644 --- a/internal/slackdotenv/slackdotenv.go +++ b/internal/slackdotenv/slackdotenv.go @@ -12,6 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package slackdotenv reads and parses .env files from a project directory. +// +// It provides a single entry point for loading environment variables defined in +// a .env file so that multiple packages (commands, config, hooks) can share the +// same parsing behavior. package slackdotenv import ( From 9160bcc5a69cb2a1600e3d6a98f6bbb87698f046 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 15:11:27 -0700 Subject: [PATCH 5/9] refactor: set command shell environment variables from hook options --- internal/hooks/hooks.go | 36 +---------------------- internal/hooks/shell.go | 43 ++++++++++++++++++++++++++++ internal/pkg/platform/localserver.go | 35 +--------------------- 3 files changed, 45 insertions(+), 69 deletions(-) diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 27b3c085..5b919e36 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -16,12 +16,10 @@ package hooks import ( "context" - "os" "strings" "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/iostreams" - "github.com/slackapi/slack-cli/internal/slackdotenv" "github.com/spf13/afero" ) @@ -57,39 +55,7 @@ func processExecOpts(ctx context.Context, opts HookExecOpts, fs afero.Fs, io ios 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 := slackdotenv.Read(fs) - if err != nil { - io.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) - } - 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, ", ")) - } - - // Whatever cmd.Env is set to will be the ONLY environment variables that the `cmd` will have access to when it runs. - // - // 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 - 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()...) + cmdEnvVars := opts.ShellEnv(ctx, fs, io) return cmdArgs, cmdArgVars, cmdEnvVars, nil } diff --git a/internal/hooks/shell.go b/internal/hooks/shell.go index f661d22e..bf8eb45d 100644 --- a/internal/hooks/shell.go +++ b/internal/hooks/shell.go @@ -15,12 +15,17 @@ package hooks import ( + "context" "fmt" "io" "os" "os/exec" "runtime" "strings" + + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/slackdotenv" + "github.com/spf13/afero" ) // ExecInterface is an interface for running shell commands in the OS @@ -92,3 +97,41 @@ type HookExecOpts struct { Stderr io.Writer Exec ExecInterface } + +// ShellEnv builds the environment variables for a hook command. +func (opts HookExecOpts) ShellEnv(ctx context.Context, fs afero.Fs, io iostreams.IOStreamer) []string { + // Gather environment variables saved to the project ".env" file + dotEnv, err := slackdotenv.Read(fs) + if err != nil { + io.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) + } + 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, ", ")) + } + + // Whatever cmd.Env is set to will be the ONLY environment variables that the `cmd` will have access to when it runs. + // + // 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 + 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 cmdEnvVars +} diff --git a/internal/pkg/platform/localserver.go b/internal/pkg/platform/localserver.go index be03bca5..44cb0939 100644 --- a/internal/pkg/platform/localserver.go +++ b/internal/pkg/platform/localserver.go @@ -35,7 +35,6 @@ import ( "github.com/slackapi/slack-cli/internal/pkg/apps" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" - "github.com/slackapi/slack-cli/internal/slackdotenv" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" @@ -309,39 +308,7 @@ func (r *LocalServer) StartDelegate(ctx context.Context) error { cmdArgs := strings.Fields(cmdStr) var cmdArgVars = cmdArgs[1:] // omit the first item because that is the command name - // Load .env file variables - dotEnv, err := slackdotenv.Read(r.clients.Fs) - if err != nil { - r.clients.IO.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) - } - if len(dotEnv) > 0 { - keys := make([]string, 0, len(dotEnv)) - for k := range dotEnv { - keys = append(keys, k) - } - r.clients.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. - // - // 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 - var cmdEnvVars []string - for k, v := range sdkManagedConnectionStartHookOpts.Env { - cmdEnvVars = append(cmdEnvVars, k+"="+v) - } - for k, v := range dotEnv { - cmdEnvVars = append(cmdEnvVars, k+"="+v) - } - cmdEnvVars = append(cmdEnvVars, os.Environ()...) + cmdEnvVars := sdkManagedConnectionStartHookOpts.ShellEnv(ctx, r.clients.Fs, r.clients.IO) cmd := sdkManagedConnectionStartHookOpts.Exec.Command(cmdEnvVars, os.Stdout, os.Stderr, nil, cmdArgs[0], cmdArgVars...) // Store command reference for lifecycle management From 7dfc00db9a9853b7cc6b8b1d911e3b5b8447e151 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 15:30:06 -0700 Subject: [PATCH 6/9] test: assert expected cases of shell environment are parsed in order --- internal/hooks/hook_executor_default_test.go | 2 +- internal/hooks/hook_executor_v2_test.go | 2 +- internal/hooks/shell_test.go | 108 +++++++++++++++++++ internal/slackdotenv/slackdotenv_test.go | 2 +- 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 internal/hooks/shell_test.go diff --git a/internal/hooks/hook_executor_default_test.go b/internal/hooks/hook_executor_default_test.go index 985fae34..35474db1 100644 --- a/internal/hooks/hook_executor_default_test.go +++ b/internal/hooks/hook_executor_default_test.go @@ -97,7 +97,7 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) { 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) + _ = afero.WriteFile(e.Fs, ".env", []byte("DOTENV_VAR=from_dotenv\n"), 0600) response, err := executor.Execute(ctx, opts) require.Equal(t, "test output", response) diff --git a/internal/hooks/hook_executor_v2_test.go b/internal/hooks/hook_executor_v2_test.go index 2a51c2ad..33c148dd 100644 --- a/internal/hooks/hook_executor_v2_test.go +++ b/internal/hooks/hook_executor_v2_test.go @@ -148,7 +148,7 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) { }, }, setup: func(fs afero.Fs) { - _ = afero.WriteFile(fs, ".env", []byte("DOTENV_VAR=from_dotenv\n"), 0644) + _ = afero.WriteFile(fs, ".env", []byte("DOTENV_VAR=from_dotenv\n"), 0600) }, check: func(t *testing.T, response string, err error, mockExec ExecInterface) { require.NoError(t, err) diff --git a/internal/hooks/shell_test.go b/internal/hooks/shell_test.go new file mode 100644 index 00000000..515d1d9c --- /dev/null +++ b/internal/hooks/shell_test.go @@ -0,0 +1,108 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hooks + +import ( + "context" + "testing" + + "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" +) + +func Test_HookExecOpts_ShellEnv(t *testing.T) { + tests := map[string]struct { + env map[string]string + osenv map[string]string + setup func(afero.Fs) + assert func(t *testing.T, result []string) + }{ + "includes opts.Env variables": { + env: map[string]string{"SLACK_CLI_XAPP": "xapp-token"}, + assert: func(t *testing.T, result []string) { + assert.Contains(t, result, "SLACK_CLI_XAPP=xapp-token") + }, + }, + "includes dotenv variables": { + setup: func(fs afero.Fs) { + _ = afero.WriteFile(fs, ".env", []byte("PROJECT_VAR=hello\n"), 0600) + }, + assert: func(t *testing.T, result []string) { + assert.Contains(t, result, "PROJECT_VAR=hello") + }, + }, + "includes os environment variables": { + osenv: map[string]string{"SHELL_ENV_TEST_VAR": "from-os"}, + assert: func(t *testing.T, result []string) { + assert.Contains(t, result, "SHELL_ENV_TEST_VAR=from-os") + }, + }, + "dotenv has higher precedence than opts.Env": { + env: map[string]string{"DUPLICATE": "from-opts"}, + setup: func(fs afero.Fs) { + _ = afero.WriteFile(fs, ".env", []byte("DUPLICATE=from-dotenv\n"), 0600) + }, + assert: func(t *testing.T, result []string) { + assert.Equal(t, "DUPLICATE=from-opts", result[0]) + assert.Equal(t, "DUPLICATE=from-dotenv", result[1]) + }, + }, + "works without dotenv file": { + env: map[string]string{"HOOK_VAR": "value"}, + assert: func(t *testing.T, result []string) { + assert.Contains(t, result, "HOOK_VAR=value") + }, + }, + "continues when dotenv file has invalid syntax": { + setup: func(fs afero.Fs) { + _ = afero.WriteFile(fs, ".env", []byte(`KEY="unclosed`), 0600) + }, + assert: func(t *testing.T, result []string) { + // Should still include os.Environ() even if .env parsing fails + assert.NotEmpty(t, result) + }, + }, + "works with nil env map": { + assert: func(t *testing.T, result []string) { + assert.NotEmpty(t, result) + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + os := slackdeps.NewOsMock() + os.AddDefaultMocks() + fs := slackdeps.NewFsMock() + cfg := config.NewConfig(fs, os) + io := iostreams.NewIOStreamsMock(cfg, fs, os) + io.AddDefaultMocks() + ctx := context.Background() + + for k, v := range tc.osenv { + t.Setenv(k, v) + } + if tc.setup != nil { + tc.setup(fs) + } + + opts := HookExecOpts{Env: tc.env} + result := opts.ShellEnv(ctx, fs, io) + tc.assert(t, result) + }) + } +} diff --git a/internal/slackdotenv/slackdotenv_test.go b/internal/slackdotenv/slackdotenv_test.go index 8c8c325d..a0a65a28 100644 --- a/internal/slackdotenv/slackdotenv_test.go +++ b/internal/slackdotenv/slackdotenv_test.go @@ -83,7 +83,7 @@ func Test_Read(t *testing.T) { 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) + _ = afero.WriteFile(tc.fs, ".env", []byte(tc.dotenv), 0600) } result, err := Read(tc.fs) if tc.expectErr { From c1382dfec911518c2eb49fe20496bf9a3b38018d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 16:38:18 -0700 Subject: [PATCH 7/9] test: confirm inline comments are ignored --- internal/slackdotenv/slackdotenv_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/slackdotenv/slackdotenv_test.go b/internal/slackdotenv/slackdotenv_test.go index a0a65a28..6326bd3f 100644 --- a/internal/slackdotenv/slackdotenv_test.go +++ b/internal/slackdotenv/slackdotenv_test.go @@ -73,6 +73,12 @@ func Test_Read(t *testing.T) { writeDotenv: true, expected: map[string]string{"URL": "https://example.com?foo=bar&baz=qux"}, }, + "strips inline comments": { + fs: afero.NewMemMapFs(), + dotenv: "FOO=bar # this is a comment\n", + writeDotenv: true, + expected: map[string]string{"FOO": "bar"}, + }, "handles empty values": { fs: afero.NewMemMapFs(), dotenv: "EMPTY=\n", From 525f6932b05d0cdb7d269906a8f9e3421a2799bb Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 17:17:53 -0700 Subject: [PATCH 8/9] feat: output warning for invalid .env file parsing --- internal/hooks/shell.go | 2 +- internal/slackdotenv/slackdotenv.go | 11 +++++++++-- internal/slackdotenv/slackdotenv_test.go | 16 +++++++++++++--- internal/slackerror/errors.go | 12 ++++++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/internal/hooks/shell.go b/internal/hooks/shell.go index bf8eb45d..a40ac1f3 100644 --- a/internal/hooks/shell.go +++ b/internal/hooks/shell.go @@ -103,7 +103,7 @@ func (opts HookExecOpts) ShellEnv(ctx context.Context, fs afero.Fs, io iostreams // Gather environment variables saved to the project ".env" file dotEnv, err := slackdotenv.Read(fs) if err != nil { - io.PrintDebug(ctx, "Warning: failed to parse .env file: %s", err) + io.PrintWarning(ctx, "%s", err) } if len(dotEnv) > 0 { keys := make([]string, 0, len(dotEnv)) diff --git a/internal/slackdotenv/slackdotenv.go b/internal/slackdotenv/slackdotenv.go index af562153..c85d58db 100644 --- a/internal/slackdotenv/slackdotenv.go +++ b/internal/slackdotenv/slackdotenv.go @@ -23,6 +23,7 @@ import ( "os" "github.com/joho/godotenv" + "github.com/slackapi/slack-cli/internal/slackerror" "github.com/spf13/afero" ) @@ -38,7 +39,13 @@ func Read(fs afero.Fs) (map[string]string, error) { if os.IsNotExist(err) { return nil, nil } - return nil, err + return nil, slackerror.Wrap(err, slackerror.ErrDotEnvFileRead). + WithMessage("Failed to read the .env file: %s", err) } - return godotenv.UnmarshalBytes(file) + vars, err := godotenv.UnmarshalBytes(file) + if err != nil { + return nil, slackerror.Wrap(err, slackerror.ErrDotEnvFileParse). + WithMessage("Failed to parse the .env file: %s", err) + } + return vars, nil } diff --git a/internal/slackdotenv/slackdotenv_test.go b/internal/slackdotenv/slackdotenv_test.go index 6326bd3f..73eff8e1 100644 --- a/internal/slackdotenv/slackdotenv_test.go +++ b/internal/slackdotenv/slackdotenv_test.go @@ -17,8 +17,10 @@ package slackdotenv import ( "testing" + "github.com/slackapi/slack-cli/internal/slackerror" "github.com/spf13/afero" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_Read(t *testing.T) { @@ -27,7 +29,7 @@ func Test_Read(t *testing.T) { dotenv string writeDotenv bool expected map[string]string - expectErr bool + expectErr string }{ "returns nil when fs is nil": { fs: nil, @@ -85,6 +87,12 @@ func Test_Read(t *testing.T) { writeDotenv: true, expected: map[string]string{"EMPTY": ""}, }, + "returns parse error for invalid syntax": { + fs: afero.NewMemMapFs(), + dotenv: `KEY="unclosed`, + writeDotenv: true, + expectErr: slackerror.ErrDotEnvFileParse, + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { @@ -92,8 +100,10 @@ func Test_Read(t *testing.T) { _ = afero.WriteFile(tc.fs, ".env", []byte(tc.dotenv), 0600) } result, err := Read(tc.fs) - if tc.expectErr { - assert.Error(t, err) + if tc.expectErr != "" { + var slackErr *slackerror.Error + require.ErrorAs(t, err, &slackErr) + assert.Equal(t, tc.expectErr, slackErr.Code) } else { assert.NoError(t, err) } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 507b75dd..4b5b50a7 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -97,6 +97,8 @@ const ( ErrDeployedAppNotSupported = "deployed_app_not_supported" ErrDocumentationGenerationFailed = "documentation_generation_failed" ErrDocsSearchFlagRequired = "docs_search_flag_required" + ErrDotEnvFileParse = "dotenv_file_parse_error" + ErrDotEnvFileRead = "dotenv_file_read_error" ErrEnterpriseNotFound = "enterprise_not_found" ErrFailedAddingCollaborator = "failed_adding_collaborator" ErrFailedCreatingApp = "failed_creating_app" @@ -692,6 +694,16 @@ Otherwise start your app for local development with: %s`, Remediation: fmt.Sprintf("Use --search flag: %s", style.Commandf("docs --search \"\"", false)), }, + ErrDotEnvFileParse: { + Code: ErrDotEnvFileParse, + Message: "Failed to parse the .env file", + }, + + ErrDotEnvFileRead: { + Code: ErrDotEnvFileRead, + Message: "Failed to read the .env file", + }, + ErrEnterpriseNotFound: { Code: ErrEnterpriseNotFound, Message: "The `enterprise` was not found", From 6cadb2d53c3df0cd63c82f40452910da89129ff2 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 26 Mar 2026 17:18:07 -0700 Subject: [PATCH 9/9] chore: claude errorcodes --- .claude/CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index afc2f803..c887e988 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -164,6 +164,12 @@ for name, tc := range tests { } ``` +### Error Handling + +- Wrap errors returned across package boundaries with `slackerror.Wrap(err, slackerror.ErrCode)` so they carry a structured error code +- Register new error codes in `internal/slackerror/errors.go`: add a constant and an entry in `ErrorCodeMap` +- Error codes are alphabetically ordered in both the constants block and `ErrorCodeMap` + ## Version Management Versions use semantic versioning with git tags (format: `v*.*.*`).