Skip to content

Commit b339400

Browse files
committed
Add dotenv support
1 parent 063b25e commit b339400

File tree

7 files changed

+171
-2
lines changed

7 files changed

+171
-2
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Goke is a build automation tool, similar to Make, but without the Makefile clutt
99
* File watching with OS-level filesystem events
1010
* Support for global hooks
1111
* Intuitive environment variable declaration at any position in the configuration
12+
* Automatic `.env` file loading
1213

1314
## Installation
1415

@@ -168,6 +169,25 @@ my-task:
168169

169170
Use `${VAR}` to reference environment variables in commands, and `$(command)` to capture command output.
170171

172+
### `.env` file support
173+
174+
Goke automatically loads `.env` files if they exist in your project directory:
175+
176+
1. `.env` — shared defaults
177+
2. `.env.local` — personal overrides (should be gitignored)
178+
179+
Later files override earlier ones. You can also specify additional files explicitly:
180+
181+
```yaml
182+
global:
183+
env_file:
184+
- .env.production
185+
environment:
186+
APP_URL: "${BASE_URL}/api"
187+
```
188+
189+
Explicit files listed in `env_file` are loaded after the defaults, and must exist or Goke will return an error. Variables from `.env` files are available for use in `environment`, `run`, and `files` sections.
190+
171191
## Events
172192

173193
Global hooks that run before/after tasks and individual commands:

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.0
55
require (
66
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
77
github.com/fsnotify/fsnotify v1.9.0
8+
github.com/joho/godotenv v1.5.1
89
github.com/stretchr/testify v1.11.1
910
github.com/theckman/yacspin v0.13.12
1011
gopkg.in/yaml.v3 v3.0.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
1010
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
1111
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
1212
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
13+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
14+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
1315
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
1416
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
1517
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

internal/dotenv.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package internal
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/joho/godotenv"
7+
)
8+
9+
// Default .env files loaded automatically in order (later overrides earlier).
10+
var defaultEnvFiles = []string{".env", ".env.local"}
11+
12+
// LoadDotenvFiles loads environment variables from .env files.
13+
// It first loads the default files (.env, .env.local) if they exist,
14+
// then loads any explicitly specified files from the config.
15+
// Explicit files must exist or an error is returned.
16+
// Later files override earlier ones.
17+
func LoadDotenvFiles(explicit []string, fs FileSystem) error {
18+
for _, f := range defaultEnvFiles {
19+
if fs.FileExists(f) {
20+
if err := godotenv.Overload(f); err != nil {
21+
return fmt.Errorf("failed to load %s: %w", f, err)
22+
}
23+
}
24+
}
25+
26+
for _, f := range explicit {
27+
if !fs.FileExists(f) {
28+
return fmt.Errorf("env_file %q not found", f)
29+
}
30+
if err := godotenv.Overload(f); err != nil {
31+
return fmt.Errorf("failed to load %s: %w", f, err)
32+
}
33+
}
34+
35+
return nil
36+
}

internal/dotenv_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package internal
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/parabrola/goke/internal/tests"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func dotenvMockNoDefaults(t *testing.T) *tests.FileSystem {
13+
fsMock := tests.NewFileSystem(t)
14+
fsMock.On("FileExists", ".env").Return(false)
15+
fsMock.On("FileExists", ".env.local").Return(false)
16+
return fsMock
17+
}
18+
19+
func TestLoadDotenvFilesSkipsMissingDefaults(t *testing.T) {
20+
fsMock := dotenvMockNoDefaults(t)
21+
22+
err := LoadDotenvFiles(nil, fsMock)
23+
require.NoError(t, err)
24+
}
25+
26+
func TestLoadDotenvFilesExplicitFileMustExist(t *testing.T) {
27+
fsMock := dotenvMockNoDefaults(t)
28+
fsMock.On("FileExists", ".env.production").Return(false)
29+
30+
err := LoadDotenvFiles([]string{".env.production"}, fsMock)
31+
assert.Error(t, err)
32+
assert.Contains(t, err.Error(), ".env.production")
33+
assert.Contains(t, err.Error(), "not found")
34+
}
35+
36+
func TestLoadDotenvFilesLoadsExplicitFiles(t *testing.T) {
37+
os.Unsetenv("DOTENV_TEST_A")
38+
os.Unsetenv("DOTENV_TEST_B")
39+
40+
dir := t.TempDir()
41+
envPath := dir + "/base.env"
42+
localPath := dir + "/local.env"
43+
44+
os.WriteFile(envPath, []byte("DOTENV_TEST_A=base\nDOTENV_TEST_B=original\n"), 0644)
45+
os.WriteFile(localPath, []byte("DOTENV_TEST_B=overridden\n"), 0644)
46+
47+
fsMock := dotenvMockNoDefaults(t)
48+
fsMock.On("FileExists", envPath).Return(true)
49+
fsMock.On("FileExists", localPath).Return(true)
50+
51+
err := LoadDotenvFiles([]string{envPath, localPath}, fsMock)
52+
require.NoError(t, err)
53+
54+
assert.Equal(t, "base", os.Getenv("DOTENV_TEST_A"))
55+
assert.Equal(t, "overridden", os.Getenv("DOTENV_TEST_B"))
56+
}
57+
58+
func TestLoadDotenvFilesLaterOverridesEarlier(t *testing.T) {
59+
os.Unsetenv("DOTENV_OVERRIDE")
60+
61+
dir := t.TempDir()
62+
first := dir + "/first.env"
63+
second := dir + "/second.env"
64+
65+
os.WriteFile(first, []byte("DOTENV_OVERRIDE=first\n"), 0644)
66+
os.WriteFile(second, []byte("DOTENV_OVERRIDE=second\n"), 0644)
67+
68+
fsMock := dotenvMockNoDefaults(t)
69+
fsMock.On("FileExists", first).Return(true)
70+
fsMock.On("FileExists", second).Return(true)
71+
72+
err := LoadDotenvFiles([]string{first, second}, fsMock)
73+
require.NoError(t, err)
74+
75+
assert.Equal(t, "second", os.Getenv("DOTENV_OVERRIDE"))
76+
}
77+
78+
func TestParseGlobalWithEnvFile(t *testing.T) {
79+
os.Unsetenv("FROM_DOTENV")
80+
81+
dir := t.TempDir()
82+
envPath := dir + "/.env.custom"
83+
os.WriteFile(envPath, []byte("FROM_DOTENV=it_works\n"), 0644)
84+
85+
config := `
86+
global:
87+
env_file:
88+
- ` + envPath + `
89+
environment:
90+
COMBINED: "${FROM_DOTENV}_and_more"
91+
`
92+
fsMock := mockCacheDoesNotExist(t)
93+
fsMock.On("FileExists", ".env").Return(false)
94+
fsMock.On("FileExists", ".env.local").Return(false)
95+
fsMock.On("FileExists", envPath).Return(true)
96+
97+
parser := NewParser(config, &clearCacheOpts, fsMock)
98+
99+
err := parser.parseGlobal()
100+
require.NoError(t, err)
101+
102+
assert.Equal(t, "it_works", os.Getenv("FROM_DOTENV"))
103+
}

internal/parser.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ type Task struct {
3333

3434
type Global struct {
3535
Shared struct {
36-
Env map[string]string `yaml:"environment,omitempty"`
37-
Events struct {
36+
EnvFile []string `yaml:"env_file,omitempty"`
37+
Env map[string]string `yaml:"environment,omitempty"`
38+
Events struct {
3839
BeforeEachRun []string `yaml:"before_each_run,omitempty"`
3940
AfterEachRun []string `yaml:"after_each_run,omitempty"`
4041
BeforeEachTask []string `yaml:"before_each_task,omitempty"`
@@ -203,6 +204,10 @@ func (p *parser) parseGlobal() error {
203204
return err
204205
}
205206

207+
if err := LoadDotenvFiles(g.Shared.EnvFile, p.fs); err != nil {
208+
return err
209+
}
210+
206211
vars, err := cli.SetEnvVariables(g.Shared.Env)
207212
if err != nil {
208213
return err

internal/parser_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ func TestTaskParsing(t *testing.T) {
9797

9898
func TestGlobalsParsing(t *testing.T) {
9999
fsMock := mockCacheDoesNotExist(t)
100+
fsMock.On("FileExists", ".env").Return(false)
101+
fsMock.On("FileExists", ".env.local").Return(false)
100102
parser := NewParser(tests.YamlConfigStub, &clearCacheOpts, fsMock)
101103

102104
parser.parseGlobal()

0 commit comments

Comments
 (0)