Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for dotenv files #104

Merged
merged 1 commit into from
Aug 4, 2024
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html

- Arguments may now specify a `type`, which works the same way as options.
- Boolean options may now be rewritten into strings using `rewrite`.
- Environment variables are now automatically parsed from `.env` files.
- Additional environment variable files can be specified with `env-file`.

### Fixed

Expand Down
49 changes: 49 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,55 @@ It is invalid to split the configuration; if the `include` clause is used, no
other keys can be specified in the `tusk.yml`, and the full task must be
defined in the included file.

### Environment Files

Environment variables are also automatically read from a `.env` file in the
same directory as `tusk.yml` before task execution. This file is optional by
default, and supports typical "dotenv" extended syntax such as quotation marks,
comments, variable substitution, and the `export` keyword.

A typical file might look like this:

```sh
FOO=foovalue
BAR=barvalue
```

Environment files can be explicitly specified as configuration at the
top-level:

```yaml
env-file: .local.env
```

This is shorthand syntax for the following:

```yaml
env-file:
- path: .local.env
required: true
```

Multiple environment files can be specified. Entries are evaluted in order, so
environment variables from later files override values specified in previous
entries.

Specifying any value for `env-file` will disable the default behavior of
auto-loading an optional `.env`. To re-enable it, specify it explicitly:

```yaml
env-file:
- path: .env
required: false
- .local.env
```

To disable loading environment files completely, pass `[]` or `/dev/null`:

```yaml
env-file: []
```

### Interpreter

By default, any command run will default to using `sh -c` as its interpreter.
Expand Down
8 changes: 8 additions & 0 deletions example/example.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# yaml-language-server: $schema=../tusk.schema.yaml
---
# Environment variables can be read from a file.
env-file:
# Environment files specified as strings are automatically required.
- .default.env
# Multiple environment files or optional files may be specified as well.
- path: .local.env
required: false

# Options can be defined at a global or task-specific level.
options:
global:
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ require (
github.com/golangci/golangci-lint v1.59.1
github.com/google/go-cmp v0.6.0
github.com/goreleaser/goreleaser v1.24.0
github.com/rliebz/ghost v0.0.0-20240127160735-e335ae595622
github.com/joho/godotenv v1.5.1
github.com/rliebz/ghost v0.1.0
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
github.com/urfave/cli v1.22.15
gopkg.in/yaml.v2 v2.4.0
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs=
github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
Expand Down Expand Up @@ -759,8 +761,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rliebz/ghost v0.0.0-20240127160735-e335ae595622 h1:cHo+/eVOLKq94+NtDF7MEc9KNgDO+WDwzMuavhkV54o=
github.com/rliebz/ghost v0.0.0-20240127160735-e335ae595622/go.mod h1:Vn62ofdLNRXMmBoEKXB93ivfiUxLDQz+134JsjoGXlY=
github.com/rliebz/ghost v0.1.0 h1:W34Z8dTGgX34tmaz1TpkLMRfPrLafkvzrgSPcbdd7+w=
github.com/rliebz/ghost v0.1.0/go.mod h1:Vn62ofdLNRXMmBoEKXB93ivfiUxLDQz+134JsjoGXlY=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
Expand Down
7 changes: 5 additions & 2 deletions runner/config.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package runner

import "github.com/rliebz/tusk/marshal"

// Config is a struct representing the format for configuration settings.
type Config struct {
Name string `yaml:"name"`
Usage string `yaml:"usage"`
Name string `yaml:"name"`
Usage string `yaml:"usage"`
EnvFile marshal.Slice[EnvFile] `yaml:"env-file"`
// The Interpreter field must be read before the config struct can be parsed
// completely from YAML. To do so, the config text parses it elsewhere in the
// code base independently from this struct.
Expand Down
78 changes: 78 additions & 0 deletions runner/env_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package runner

import (
"maps"
"os"

"github.com/joho/godotenv"

"github.com/rliebz/tusk/marshal"
)

// EnvFile is a dotenv file that should be parsed during configuration start.
type EnvFile struct {
Path string `yaml:"path"`
Required bool `yaml:"required"`
}

// UnmarshalYAML allows a string to represent a required path.
func (f *EnvFile) UnmarshalYAML(unmarshal func(any) error) error {
var path string
pathCandidate := marshal.UnmarshalCandidate{
Unmarshal: func() error { return unmarshal(&path) },
Assign: func() { *f = EnvFile{Path: path, Required: true} },
}

type envFileType EnvFile // Use new type to avoid recursion
var envFileItem envFileType
envFileCandidate := marshal.UnmarshalCandidate{
Unmarshal: func() error { return unmarshal(&envFileItem) },
Assign: func() { *f = EnvFile(envFileItem) },
}

return marshal.UnmarshalOneOf(pathCandidate, envFileCandidate)
}

// loadEnvFiles sets env vars from a set of file configs. Values are not
// overridden.
//
// If no files are specified, it will load from an optional default of .env.
// If an empty list is specified, no files will be loaded.
func loadEnvFiles(envFiles []EnvFile) error {
// An explicit [] is an obvious attempt to remove the default, so check only
// for nilness.
if envFiles == nil {
envFiles = []EnvFile{{Path: ".env", Required: false}}
}

envMap := make(map[string]string)
for _, envFile := range envFiles {
m, err := readEnvFile(envFile)
if err != nil {
return err
}

maps.Copy(envMap, m)
}

for k, v := range envMap {
if _, ok := os.LookupEnv(k); !ok {
if err := os.Setenv(k, v); err != nil {
return err
}
}
}

return nil
}

func readEnvFile(envFile EnvFile) (map[string]string, error) {
m, err := godotenv.Read(envFile.Path)
switch {
case !envFile.Required && os.IsNotExist(err):
case err != nil:
return nil, err
}

return m, nil
}
178 changes: 178 additions & 0 deletions runner/env_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package runner

import (
"os"
"path/filepath"
"testing"

"github.com/rliebz/ghost"
"github.com/rliebz/ghost/be"
"gopkg.in/yaml.v2"
)

func TestEnvFile_UnmarshalYAML(t *testing.T) {
tests := []struct {
name string
data string
want EnvFile
}{
{
name: "empty",
data: ``,
want: EnvFile{},
},
{
name: "string",
data: `"dot.env"`,
want: EnvFile{
Path: "dot.env",
Required: true,
},
},
{
name: "object required",
data: `{path: dot.env, required: true}`,
want: EnvFile{
Path: "dot.env",
Required: true,
},
},
{
name: "object not required",
data: `{path: dot.env, required: false}`,
want: EnvFile{
Path: "dot.env",
Required: false,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := ghost.New(t)

var envFile EnvFile
err := yaml.UnmarshalStrict([]byte(tt.data), &envFile)
g.NoError(err)

g.Should(be.Equal(envFile, tt.want))
})
}
}

func Test_loadEnvFiles(t *testing.T) {
t.Run("default used", func(t *testing.T) {
g := ghost.New(t)
stashEnv(t)
tmpdir := useTempDir(t)

t.Setenv("BAZ", "bazvalue")

// Just use a little bit of all the fancy syntax
data := []byte(`
# comment
FOO=foovalue
export BAR=barvalue
BAZ=newvalue
QUUX=${FOO}
`)

err := os.WriteFile(filepath.Join(tmpdir, ".env"), data, 0o644)
g.NoError(err)

err = loadEnvFiles(nil)
g.NoError(err)

g.Should(be.All(
be.Equal(os.Getenv("FOO"), "foovalue"),
be.Equal(os.Getenv("BAR"), "barvalue"),
be.Equal(os.Getenv("BAZ"), "bazvalue"),
be.Equal(os.Getenv("QUUX"), "foovalue"),
))
})

t.Run("default not found", func(t *testing.T) {
g := ghost.New(t)
stashEnv(t)
useTempDir(t)

err := loadEnvFiles(nil)
g.NoError(err)
})

t.Run("dev null", func(t *testing.T) {
g := ghost.New(t)
stashEnv(t)
useTempDir(t)

err := loadEnvFiles([]EnvFile{{Path: "/dev/null"}})
g.NoError(err)
})

t.Run("empty list", func(t *testing.T) {
g := ghost.New(t)
stashEnv(t)
tmpdir := useTempDir(t)

t.Setenv("BAZ", "bazvalue")

err := os.WriteFile(filepath.Join(tmpdir, ".env"), []byte(`FOO=foovalue`), 0o644)
g.NoError(err)

err = loadEnvFiles([]EnvFile{})
g.NoError(err)

g.Should(be.Zero(os.Getenv("FOO")))
})

t.Run("required found", func(t *testing.T) {
g := ghost.New(t)
stashEnv(t)
tmpdir := useTempDir(t)

err := os.WriteFile(filepath.Join(tmpdir, ".env"), []byte("FOO=foovalue"), 0o644)
g.NoError(err)

err = loadEnvFiles([]EnvFile{
{Path: ".env", Required: true},
})
g.NoError(err)

g.Should(be.Equal(os.Getenv("FOO"), "foovalue"))
})

t.Run("required not found", func(t *testing.T) {
g := ghost.New(t)
stashEnv(t)
useTempDir(t)

err := loadEnvFiles([]EnvFile{
{Path: ".env", Required: true},
})
g.Should(be.ErrorContaining(err, "open .env:"))
g.Should(be.True(os.IsNotExist(err)))
})

t.Run("overrides earlier values", func(t *testing.T) {
g := ghost.New(t)
stashEnv(t)
tmpdir := useTempDir(t)

err := os.WriteFile(filepath.Join(tmpdir, "1.env"), []byte("FOO=one"), 0o644)
g.NoError(err)
err = os.WriteFile(filepath.Join(tmpdir, "2.env"), []byte("FOO=two"), 0o644)
g.NoError(err)
err = os.WriteFile(filepath.Join(tmpdir, "3.env"), []byte("BAR=three"), 0o644)
g.NoError(err)

err = loadEnvFiles([]EnvFile{
{Path: "1.env"},
{Path: "2.env"},
{Path: "3.env"},
})
g.NoError(err)

g.Should(be.Equal(os.Getenv("FOO"), "two"))
g.Should(be.Equal(os.Getenv("BAR"), "three"))
})
}
Loading
Loading