Skip to content

feat: load environment variables from '.env' file for hook commands#436

Open
zimeg wants to merge 2 commits intomainfrom
zimeg-feat-load-hook-dotenv
Open

feat: load environment variables from '.env' file for hook commands#436
zimeg wants to merge 2 commits intomainfrom
zimeg-feat-load-hook-dotenv

Conversation

@zimeg
Copy link
Member

@zimeg zimeg commented Mar 24, 2026

Changelog

Environment variables saved to a .env file are now loaded into the process before hook scripts run. This brings meaningful improvement to the slack run command foremost as additional dependencies are no longer required for loading environment variables into the runtime context.

Other commands, such as the slack deploy command and the slack manifest command, also benefit from this since the underlying hooks can be more expressive in customizations and configured to custom environments.

Summary

This PR loads environment variables from the .env file for hook commands. These variables are loaded before each hook command executes to ensure correctness 🌲 ✨

We also fix orderings of environment variable precedence within hooks to be:

  1. Provided variables to hooks - Ex: The SLACK_CLI_XOXP token provided with the run command.
  2. Saved ".env" file - Ex: The developer's project keeps a .env. file.
  3. Existing shell environment - Ex: Prior EXPORT or set variables otherwise. This is most important!

Preview

demo.mov

Reviewers

A few test cases might be interesting to saved variables:

$ slack create asdf -t slack-samples/bolt-js-assistant-template
$ cd asdf
$ vim .env
OPENAI_API_KEY=sk-proj-example-123  # Replace please!
$ npm uninstall dotenv
$ diff app.js
- import 'dotenv/config';           # Delete this line
$ slack run                         # File is read
$ OPENAI_API_KEY=oopsies slack run  # Shell overrides

Notes

We might follow up with similar .env patterns and I'm hoping these changes guide decent direction to placement of loading environment variables for hooks!

  • Earlier exploration attempted to read environment variables during the CLI setup but that required configuration changes that seemed incorrect to make. I understand now the internal/config/dotenv package is for internal configurations instead of developer application variables.
  • The existing clients.Config.ManifestEnv attribute is used for manifest and trigger commands but not run or the deploy commands for Bolt apps. I'm unsure that we should continue to support this as we bring enhancement to a project ".env" file itself? Regardless, it wasn't the right rabbit hole...

Requirements

@zimeg zimeg added this to the Next Release milestone Mar 24, 2026
@zimeg zimeg self-assigned this Mar 24, 2026
@zimeg zimeg added enhancement M-T: A feature request for new functionality semver:minor Use on pull requests to describe the release version increment labels Mar 24, 2026
@codecov
Copy link

codecov bot commented Mar 24, 2026

Codecov Report

❌ Patch coverage is 62.22222% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.27%. Comparing base (97fbfe8) to head (fb0cce1).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
internal/pkg/platform/localserver.go 0.00% 14 Missing ⚠️
internal/hooks/hooks.go 88.00% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #436      +/-   ##
==========================================
- Coverage   70.27%   70.27%   -0.01%     
==========================================
  Files         220      220              
  Lines       18498    18534      +36     
==========================================
+ Hits        12999    13024      +25     
- Misses       4324     4336      +12     
+ Partials     1175     1174       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Member Author

@zimeg zimeg left a comment

Choose a reason for hiding this comment

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

👾 A few thoughts for wonderful reviewers-

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: The run command uses a separate hook process to handle automatic restarts with file watching so we duplicate some logic here!

// 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.

@zimeg zimeg marked this pull request as ready for review March 24, 2026 22:43
@zimeg zimeg requested a review from a team as a code owner March 24, 2026 22:43
Copy link
Member

@mwbrooks mwbrooks left a comment

Choose a reason for hiding this comment

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

🙌🏻 Woohoo, this is so awesome to see landing!

🥾 I think we have a few more steps to tighten things up. I've left a few suggestions in-line around consolidating code and testing edge-cases that users will hit. Below are a few more suggestions.

suggestion(non-blocking): We have some existing dotenv logic in internal/config/dotenv.go. This seems like a reasonable place to put our new parsing logic, so that everything is in one place. Or, rename it to something that feels better to us. This could be a follow-up PR but we should make sure that we don't fragment our dotenv logic.

suggestion: I think we should update the PR title and CHANGELOG description to focus a little more on the use-facing feature. For example: "feat: support loading a dotenv '.env' file for your app"

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?

)
},
},
"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.

Comment on lines +91 to +100
// 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
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!

Comment on lines +326 to +335
// 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
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: This logic feels important and since it's used in 2 places, I think we should consider DRY'ing up the code and putting the logic into 1 place. For example, internal/config/dotenv.go (existing) or rename the file to internal/slackdotenv if we want something that doesn't name collide with the dotenv dependency.

Additionally, a single function would make future improvements easier. For example, we may want to introduce a --dotenv-overwrite flag and { "dotenv-overwrite": true config value that allow the .env to overwrite session variables. This seems to be a common use-case because most dotenv libraries support it, including our package with godotenv.Overload().

Note: This would also allow us to unit test the scenarios in internal/config/dotenv_test.go instead of in the localserver_test.go and hooks_test.go.

// 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.

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, ", "))

"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.

Comment on lines +32 to +46
// 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)
}
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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement M-T: A feature request for new functionality semver:minor Use on pull requests to describe the release version increment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants