├── fixtures ├── handler │ ├── .env │ ├── valid.env │ └── invalid.env └── cmd │ ├── test.sh │ ├── test.ps1 │ └── devel.env ├── .gitignore ├── main.go ├── env ├── map.go ├── map_test.go ├── slice.go ├── env.go ├── slice_test.go └── env_test.go ├── go.mod ├── cmd ├── exec.go ├── cmd.go ├── exec_win.go ├── flags.go ├── exec_test.go ├── cmd_win_test.go ├── handler.go ├── cmd_test.go └── handler_test.go ├── fs ├── fs.go └── fs_test.go ├── .golangci.toml ├── LICENSE-MIT ├── helpers ├── assert.go └── assert_test.go ├── go.sum ├── .goreleaser.yaml ├── Makefile ├── .github └── workflows │ └── devel.yml ├── README.md └── LICENSE-APACHE /fixtures/handler/.env: -------------------------------------------------------------------------------- 1 | # Default .env testing configuration 2 | SERVER=localhost 3 | IP=192.168.1.120 4 | LEVEL=info 5 | -------------------------------------------------------------------------------- /fixtures/handler/valid.env: -------------------------------------------------------------------------------- 1 | # Custom `devel.env` testing configuration 2 | HOST=127.0.0.1 3 | PORT=8080 4 | DEBUG=true 5 | LOG_LEVEL=info 6 | -------------------------------------------------------------------------------- /fixtures/handler/invalid.env: -------------------------------------------------------------------------------- 1 | { 2 | "HOST": "127.0.0.1", 3 | "PORT": "8080", 4 | "DEBUG": "true", 5 | "LOG_LEVEL": "info" 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *bin* 2 | *debug* 3 | vendor 4 | *.tmp 5 | *.txt 6 | *.log 7 | *.bin 8 | *envf* 9 | /*.sh 10 | /*.env 11 | .DS_Store 12 | 13 | dist/ 14 | 15 | !fixtures/handler/.env 16 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/joseluisq/enve/cmd" 8 | ) 9 | 10 | func main() { 11 | if err := cmd.Execute(os.Args); err != nil { 12 | fmt.Fprintln(os.Stderr, err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /env/map.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Map map[string]string 8 | 9 | func (e Map) Array() []string { 10 | vars := []string{} 11 | for k, v := range e { 12 | vars = append(vars, fmt.Sprintf("%s=%s", k, v)) 13 | } 14 | return vars 15 | } 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joseluisq/enve 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/joho/godotenv v1.5.1 7 | github.com/joseluisq/cline v1.0.0-beta.4 8 | github.com/stretchr/testify v1.11.1 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /fixtures/cmd/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | echo "DB_PROTOCOL=$DB_PROTOCOL" 6 | echo "DB_HOST=$DB_HOST" 7 | echo "DB_PORT=$DB_PORT" 8 | echo "DB_DEFAULT_CHARACTER_SET=$DB_DEFAULT_CHARACTER_SET" 9 | echo "DB_EXPORT_GZIP=$DB_EXPORT_GZIP" 10 | echo "DB_EXPORT_FILE_PATH=$DB_EXPORT_FILE_PATH" 11 | echo "DB_NAME=$DB_NAME" 12 | echo "DB_USERNAME=$DB_USERNAME" 13 | echo "DB_PASSWORD=$DB_PASSWORD" 14 | echo "DB_ARGS=$DB_ARGS" 15 | -------------------------------------------------------------------------------- /fixtures/cmd/test.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop"; 2 | 3 | Write-Output "DB_PROTOCOL=$env:DB_PROTOCOL" 4 | Write-Output "DB_HOST=$env:DB_HOST" 5 | Write-Output "DB_PORT=$env:DB_PORT" 6 | Write-Output "DB_DEFAULT_CHARACTER_SET=$env:DB_DEFAULT_CHARACTER_SET" 7 | Write-Output "DB_EXPORT_GZIP=$env:DB_EXPORT_GZIP" 8 | Write-Output "DB_EXPORT_FILE_PATH=$env:DB_EXPORT_FILE_PATH" 9 | Write-Output "DB_NAME=$env:DB_NAME" 10 | Write-Output "DB_USERNAME=$env:DB_USERNAME" 11 | Write-Output "DB_PASSWORD=$env:DB_PASSWORD" 12 | Write-Output "DB_ARGS=$env:DB_ARGS" 13 | 14 | if ($LastExitCode -gt 0) { exit $LastExitCode } 15 | -------------------------------------------------------------------------------- /fixtures/cmd/devel.env: -------------------------------------------------------------------------------- 1 | ## MySQL Export - Fixture settings 2 | ## ------------------------------- 3 | 4 | # Connection settings (optional) 5 | DB_PROTOCOL=tcp 6 | DB_HOST=127.0.0.1 7 | DB_PORT=3306 8 | DB_DEFAULT_CHARACTER_SET=utf8 9 | 10 | # Database settings (required) 11 | DB_NAME="dbname" 12 | DB_USERNAME="username" 13 | DB_PASSWORD="passwd" 14 | 15 | # GZip export file (optional) 16 | DB_EXPORT_GZIP=true 17 | 18 | # SQL or Gzip export file (optional). 19 | # If `DB_IMPORT_GZIP` is `true` then file name should be `database_name.sql.gz` 20 | DB_EXPORT_FILE_PATH="$DB_NAME.sql.gz" 21 | 22 | # Additional arguments (optional) 23 | DB_ARGS= 24 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | ) 11 | 12 | // execCmd executes a command along with its env variables 13 | func execCmd(tailArgs []string, chdirPath string, newEnv bool, envVars []string) (err error) { 14 | cmdIn := tailArgs[0] 15 | c, err := exec.LookPath(cmdIn) 16 | if err != nil { 17 | return fmt.Errorf("error: executable '%s' was not found.\n%v", cmdIn, err) 18 | } 19 | cmd := exec.Command(c, tailArgs[1:]...) 20 | cmd.Dir = chdirPath 21 | if newEnv { 22 | cmd.Env = envVars 23 | } 24 | cmd.Stderr = os.Stderr 25 | cmd.Stdin = os.Stdin 26 | cmd.Stdout = os.Stdout 27 | return cmd.Run() 28 | } 29 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/joseluisq/cline/app" 5 | "github.com/joseluisq/cline/handler" 6 | ) 7 | 8 | // Build-time application values 9 | var ( 10 | versionNumber string = "devel" 11 | buildTime string 12 | buildCommit string 13 | ) 14 | 15 | // Execute adds all child commands to the root command and sets flags appropriately. 16 | func Execute(args []string) error { 17 | ap := app.New() 18 | ap.Name = "enve" 19 | ap.Summary = "Run a program in a modified environment providing an optional .env file or variables from stdin" 20 | ap.Version = versionNumber 21 | ap.BuildTime = buildTime 22 | ap.BuildCommit = buildCommit 23 | ap.Flags = Flags 24 | ap.Handler = appHandler 25 | 26 | return handler.New(ap).Run(args) 27 | } 28 | -------------------------------------------------------------------------------- /env/map_test.go: -------------------------------------------------------------------------------- 1 | package env_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/joseluisq/enve/env" 7 | "github.com/joseluisq/enve/helpers" 8 | ) 9 | 10 | func TestMap_Array(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | input env.Map 14 | expected []string 15 | }{ 16 | { 17 | name: "should return an empty slice for an empty map", 18 | input: env.Map{}, 19 | expected: []string{}, 20 | }, 21 | { 22 | name: "should return array with valid values", 23 | input: env.Map{ 24 | "KEY2": "value2", 25 | "KEY1": "value1", 26 | }, 27 | expected: []string{ 28 | "KEY2=value2", 29 | "KEY1=value1", 30 | }, 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | actual := tt.input.Array() 36 | helpers.ElementsContain(t, tt.expected, actual, "Array output should match") 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cmd/exec_win.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | ) 11 | 12 | // execCmd executes a command along with its env variables 13 | func execCmd(tailArgs []string, chdirPath string, newEnv bool, envVars []string) (err error) { 14 | ps, err := exec.LookPath("powershell.exe") 15 | if err != nil { 16 | return fmt.Errorf("error: executable 'powershell.exe' was not found.\n%v", err) 17 | } 18 | args := []string{"-NoProfile", "-NonInteractive", "-Command"} 19 | args = append(args, "$ErrorActionPreference = \"Stop\"; ") 20 | args = append(args, tailArgs...) 21 | args = append(args, "; if ($LastExitCode -gt 0) { exit $LastExitCode };") 22 | cmd := exec.Command(ps, args...) 23 | cmd.Dir = chdirPath 24 | if newEnv { 25 | cmd.Env = envVars 26 | } 27 | cmd.Stderr = os.Stderr 28 | cmd.Stdin = os.Stdin 29 | cmd.Stdout = os.Stdout 30 | return cmd.Run() 31 | } 32 | -------------------------------------------------------------------------------- /fs/fs.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func FileExists(filePath string) error { 9 | if filePath == "" { 10 | return fmt.Errorf("file path was empty or not provided") 11 | } 12 | if info, err := os.Stat(filePath); err != nil { 13 | return fmt.Errorf("error: cannot access file '%s'.\n%v", filePath, err) 14 | } else { 15 | if info.IsDir() { 16 | return fmt.Errorf("error: file path '%s' is a directory", filePath) 17 | } 18 | return nil 19 | } 20 | } 21 | 22 | func DirExists(dirPath string) error { 23 | if dirPath == "" { 24 | return fmt.Errorf("error: directory path was empty or not provided") 25 | } 26 | if info, err := os.Stat(dirPath); err != nil { 27 | return fmt.Errorf("error: cannot access directory '%s'.\n%v", dirPath, err) 28 | } else { 29 | if !info.IsDir() { 30 | return fmt.Errorf("error: directory path '%s' is a file", dirPath) 31 | } 32 | return nil 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.golangci.toml: -------------------------------------------------------------------------------- 1 | [run] 2 | timeout = "10m" 3 | skip-files = [] 4 | 5 | [linters-settings] 6 | 7 | [linters-settings.govet] 8 | check-shadowing = false 9 | 10 | [linters-settings.golint] 11 | min-confidence = 0.0 12 | 13 | [linters-settings.gocyclo] 14 | min-complexity = 14.0 15 | 16 | [linters-settings.maligned] 17 | suggest-new = true 18 | 19 | [linters-settings.goconst] 20 | min-len = 3.0 21 | min-occurrences = 4.0 22 | 23 | [linters-settings.misspell] 24 | locale = "US" 25 | 26 | [linters-settings.funlen] 27 | lines = 230 28 | statements = 120 29 | 30 | [linters] 31 | enable-all = true 32 | disable = [ 33 | "gocyclo", 34 | "gosec", 35 | "dupl", 36 | "maligned", 37 | "lll", 38 | "unparam", 39 | "prealloc", 40 | "scopelint", 41 | "gochecknoinits", 42 | "gochecknoglobals", 43 | "godox", 44 | "gocognit", 45 | "bodyclose", 46 | "wsl", 47 | "gomnd", 48 | "stylecheck", 49 | ] 50 | -------------------------------------------------------------------------------- /env/slice.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "strings" 7 | ) 8 | 9 | type Slice []string 10 | 11 | func (e Slice) Text() string { 12 | return strings.Join(e, "\n") 13 | } 14 | 15 | func (e Slice) Environ() Environment { 16 | var environ Environment 17 | for _, s := range e { 18 | // NOTE: skip non-key=value pair 19 | pair := strings.SplitN(s, "=", 2) 20 | if len(pair) < 2 { 21 | continue 22 | } 23 | v := EnvironmentVar{Name: pair[0], Value: pair[1]} 24 | environ.Env = append(environ.Env, v) 25 | } 26 | return environ 27 | } 28 | 29 | func (e Slice) JSON() ([]byte, error) { 30 | environ := e.Environ() 31 | jsonb, err := json.Marshal(environ) 32 | if err != nil { 33 | return []byte(nil), err 34 | } 35 | return jsonb, nil 36 | } 37 | 38 | func (e Slice) XML() ([]byte, error) { 39 | environ := e.Environ() 40 | xmlb, err := xml.Marshal(environ) 41 | if err != nil { 42 | return []byte(nil), err 43 | } 44 | return xmlb, nil 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /helpers/assert.go: -------------------------------------------------------------------------------- 1 | // Package helpers provide utilities for the project including testing. 2 | package helpers 3 | 4 | import ( 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // ElementsContain asserts that all elements in listB are contained in listA. 12 | func ElementsContain(t assert.TestingT, listA any, listB any, msgAndArgs ...any) (ok bool) { 13 | aVal := reflect.ValueOf(listA) 14 | bVal := reflect.ValueOf(listB) 15 | 16 | if aVal.Kind() != reflect.Slice || bVal.Kind() != reflect.Slice { 17 | return assert.Fail(t, "ElementsContain only accepts slice arguments", msgAndArgs...) 18 | } 19 | 20 | // Build multiset for listA 21 | counts := make(map[any]int) 22 | for i := 0; i < aVal.Len(); i++ { 23 | val := aVal.Index(i).Interface() 24 | counts[val]++ 25 | } 26 | 27 | // Check that each element in listB is present in listA 28 | for i := 0; i < bVal.Len(); i++ { 29 | val := bVal.Index(i).Interface() 30 | if counts[val] == 0 { 31 | return assert.Fail( 32 | t, fmt.Sprintf("Expected element %+v not found in listA: %+v", val, listA), msgAndArgs..., 33 | ) 34 | } 35 | counts[val]-- 36 | } 37 | 38 | return true 39 | } 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 4 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 5 | github.com/joseluisq/cline v1.0.0-beta.4 h1:ncDW3Fc4FA4wYWRHjtsKlPk7kuYjWmhZggZ27RCUEOo= 6 | github.com/joseluisq/cline v1.0.0-beta.4/go.mod h1:0wgmKF0JaVV3ADJYsem1b71ofwiMLQsKkgXetbJKH74= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 10 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 14 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 15 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: enve 4 | 5 | dist: bin 6 | 7 | env: 8 | - GO111MODULE=on 9 | 10 | builds: 11 | - binary: enve 12 | main: main.go 13 | env: 14 | - CGO_ENABLED=0 15 | ldflags: 16 | - -s -w -X github.com/joseluisq/enve/cmd.versionNumber={{.Version}} -X github.com/joseluisq/enve/cmd.buildCommit={{.Commit}} -X github.com/joseluisq/enve/cmd.buildTime={{.Date}} 17 | goos: 18 | - linux 19 | - darwin 20 | - windows 21 | - freebsd 22 | - openbsd 23 | goarch: 24 | - amd64 25 | - 386 26 | - arm 27 | - arm64 28 | - ppc64le 29 | goarm: 30 | - 7 31 | - 6 32 | - 5 33 | ignore: 34 | - goos: darwin 35 | goarch: 386 36 | - goos: openbsd 37 | goarch: arm 38 | - goos: openbsd 39 | goarch: arm64 40 | - goos: freebsd 41 | goarch: arm64 42 | 43 | archives: 44 | - id: enve 45 | name_template: '{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 46 | format: tar.gz 47 | files: 48 | - LICENSE-APACHE 49 | - LICENSE-MIT 50 | 51 | release: 52 | prerelease: auto 53 | 54 | checksum: 55 | name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt" 56 | -------------------------------------------------------------------------------- /cmd/flags.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/joseluisq/cline/flag" 5 | ) 6 | 7 | var Flags = []flag.Flag{ 8 | flag.FlagString{ 9 | Name: "file", 10 | Aliases: []string{"f"}, 11 | Value: ".env", 12 | Summary: "Load environment variables from a file path (optional)", 13 | }, 14 | flag.FlagString{ 15 | Name: "output", 16 | Aliases: []string{"o"}, 17 | Value: "text", 18 | Summary: "Output environment variables using text, json or xml format", 19 | }, 20 | flag.FlagBool{ 21 | Name: "overwrite", 22 | Aliases: []string{"w"}, 23 | Value: false, 24 | Summary: "Overwrite environment variables if already set", 25 | }, 26 | flag.FlagString{ 27 | Name: "chdir", 28 | Aliases: []string{"c"}, 29 | Summary: "Change currrent working directory", 30 | }, 31 | flag.FlagBool{ 32 | Name: "new-environment", 33 | Aliases: []string{"n"}, 34 | Value: false, 35 | Summary: "Start a new environment with only variables from the .env file or stdin", 36 | }, 37 | flag.FlagBool{ 38 | Name: "ignore-environment", 39 | Aliases: []string{"i"}, 40 | Value: false, 41 | Summary: "Starts with an empty environment, ignoring any existing environment variables", 42 | }, 43 | flag.FlagBool{ 44 | Name: "no-file", 45 | Aliases: []string{"z"}, 46 | Value: false, 47 | Summary: "Do not load a .env file", 48 | }, 49 | flag.FlagBool{ 50 | Name: "stdin", 51 | Aliases: []string{"s"}, 52 | Value: false, 53 | Summary: "Read only environment variables from stdin and ignore the .env file", 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | 8 | "github.com/joho/godotenv" 9 | "github.com/joseluisq/enve/fs" 10 | ) 11 | 12 | type EnvironmentVar struct { 13 | Name string `json:"name"` 14 | Value string `json:"value"` 15 | } 16 | 17 | // Environment defines JSON/XML data structure 18 | type Environment struct { 19 | Env []EnvironmentVar `json:"environment"` 20 | } 21 | 22 | type EnvFile interface { 23 | Load(overload bool) error 24 | Parse() (Map, error) 25 | Close() error 26 | } 27 | 28 | type EnvReader interface { 29 | Load(overload bool) error 30 | Parse() (Map, error) 31 | } 32 | 33 | type Env struct { 34 | r io.Reader 35 | closed bool 36 | } 37 | 38 | func FromReader(r io.Reader) EnvReader { 39 | return &Env{r: r} 40 | } 41 | 42 | func FromPath(filePath string) (EnvFile, error) { 43 | if err := fs.FileExists(filePath); err != nil { 44 | return nil, err 45 | } 46 | f, err := os.Open(filePath) 47 | if err != nil { 48 | return nil, err 49 | } 50 | return &Env{r: f}, nil 51 | } 52 | 53 | func (e *Env) Load(overload bool) error { 54 | envMap, err := e.Parse() 55 | if err != nil { 56 | return err 57 | } 58 | 59 | currentEnv := map[string]bool{} 60 | rawEnv := os.Environ() 61 | for _, rawEnvLine := range rawEnv { 62 | key := strings.Split(rawEnvLine, "=")[0] 63 | currentEnv[key] = true 64 | } 65 | 66 | for key, value := range envMap { 67 | if !currentEnv[key] || overload { 68 | _ = os.Setenv(key, value) 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func (e *Env) Parse() (Map, error) { 76 | return godotenv.Parse(e.r) 77 | } 78 | 79 | func (e *Env) Close() error { 80 | if !e.closed { 81 | if f, ok := e.r.(*os.File); ok { 82 | e.closed = true 83 | return f.Close() 84 | } 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKG_TARGET=linux 2 | PKG_BIN=./bin/enve 3 | PKG_TAG=$(shell git tag -l --contains HEAD) 4 | BUILD_TIME ?= $(shell date -u '+%Y-%m-%dT%H:%m:%S') 5 | 6 | export GO111MODULE := on 7 | 8 | ####################################### 9 | ############# Development ############# 10 | ####################################### 11 | 12 | install: 13 | @go version 14 | @go install honnef.co/go/tools/cmd/staticcheck@2023.1.3 15 | @go mod download 16 | @go mod tidy 17 | .ONESHELL: install 18 | 19 | watch: 20 | @refresh run 21 | .ONESHELL: watch 22 | 23 | dev.release: 24 | set -e 25 | set -u 26 | 27 | @goreleaser release --skip-publish --rm-dist --snapshot 28 | .ONESHELL: dev.release 29 | 30 | 31 | ####################################### 32 | ########### Utility tasks ############# 33 | ####################################### 34 | 35 | test: 36 | @go version 37 | @staticcheck ./... 38 | @go vet ./... 39 | @go test $$(go list ./...) \ 40 | -v -timeout 30s -race -coverprofile=coverage.txt -covermode=atomic 41 | .PHONY: test 42 | 43 | coverage: 44 | @bash -c "bash <(curl -s https://codecov.io/bash)" 45 | .PHONY: coverage 46 | 47 | tidy: 48 | @go mod tidy 49 | .PHONY: tidy 50 | 51 | fmt: 52 | @find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done 53 | .PHONY: fmt 54 | 55 | dev_release: 56 | @go version 57 | @goreleaser release --snapshot --rm-dist 58 | .PHONY: dev_release 59 | 60 | build: 61 | @go version 62 | @go build -v \ 63 | -ldflags "-s -w \ 64 | -X 'github.com/joseluisq/enve/cmd.versionNumber=0.0.0' \ 65 | -X 'github.com/joseluisq/enve/cmd.buildTime=$(BUILD_TIME)'" \ 66 | -a -o bin/enve main.go 67 | .PHONY: build 68 | 69 | 70 | ####################################### 71 | ########## Production tasks ########### 72 | ####################################### 73 | 74 | prod.release: 75 | set -e 76 | set -u 77 | 78 | @go version 79 | @git tag $(GIT_TAG) --sign -m "$(GIT_TAG)" 80 | @goreleaser release --clean --skip=publish --skip=validate 81 | .ONESHELL: prod.release 82 | 83 | prod.release.ci: 84 | set -e 85 | set -u 86 | 87 | @go version 88 | @git tag $(GIT_TAG) --sign -m "$(GIT_TAG)" 89 | @curl -sL https://git.io/goreleaser | bash 90 | .ONESHELL: prod.release.ci 91 | -------------------------------------------------------------------------------- /.github/workflows/devel.yml: -------------------------------------------------------------------------------- 1 | name: devel 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | paths: 9 | - .github/workflows/devel.yml 10 | - go.mod 11 | - go.sum 12 | - '**.go' 13 | - fixtures/** 14 | 15 | jobs: 16 | test: 17 | name: Tests 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | go: 22 | - 1.23.x 23 | - 1.24.x 24 | - 1.25.x 25 | os: 26 | - ubuntu-22.04 27 | - ubuntu-22.04-arm 28 | - macos-14 29 | - windows-2022 30 | - windows-11-arm 31 | runs-on: ${{ matrix.os}} 32 | steps: 33 | - name: Install 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version: ${{ matrix.go }} 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | - name: format 40 | run: | 41 | go fmt ./... 42 | - name: Vet 43 | run: | 44 | go vet ./... 45 | - uses: dominikh/staticcheck-action@v1 46 | with: 47 | version: "latest" 48 | install-go: false 49 | cache-key: ${{ matrix.go }} 50 | - name: Tests 51 | run: | 52 | go test $(go list ./... | grep -v main.go) -v -timeout 30s -coverprofile=coverage.txt -covermode=atomic 53 | - name: Coverage 54 | uses: codecov/codecov-action@v5 55 | with: 56 | flags: unittests 57 | verbose: true 58 | name: codecov-enve 59 | gcov_ignore: | 60 | main.go 61 | token: ${{ secrets.CODECOV_TOKEN }} 62 | - name: Build 63 | shell: bash 64 | run: | 65 | now=$(date -u '+%Y-%m-%dT%H:%m:%S') 66 | go build -v \ 67 | -ldflags "-s -w \ 68 | -X 'github.com/joseluisq/enve/cmd.versionNumber=0.0.0' \ 69 | -X 'github.com/joseluisq/enve/cmd.buildTime=${now}' \ 70 | -X 'github.com/joseluisq/enve/cmd.buildCommit=${GITHUB_SHA}'" \ 71 | -a -o bin/enve main.go 72 | bin/enve --version 73 | bin/enve --help 74 | - name: Environment 75 | id: vars 76 | shell: bash 77 | run: | 78 | echo "Using go at: $(which go)" 79 | echo "Go version: $(go version)" 80 | echo " Go environment:" 81 | go env 82 | echo " System environment:" 83 | env 84 | echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 85 | echo "go_cache=$(go env GOCACHE)" >> $GITHUB_OUTPUT 86 | - name: Cache 87 | uses: actions/cache@v4 88 | with: 89 | path: ${{ steps.vars.outputs.go_cache }} 90 | key: ${{ runner.os }}-${{ matrix.go }}-go-ci-${{ hashFiles('**/go.sum') }} 91 | restore-keys: | 92 | ${{ runner.os }}-${{ matrix.go }}-go-ci 93 | -------------------------------------------------------------------------------- /fs/fs_test.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFileExists(t *testing.T) { 12 | t.Run("should return an error for an empty file path", func(t *testing.T) { 13 | err := FileExists("") 14 | assert.Error(t, err, "should return an error") 15 | assert.Equal(t, "file path was empty or not provided", err.Error(), "error message should match") 16 | }) 17 | 18 | t.Run("should return an error for a non-existent file", func(t *testing.T) { 19 | err := FileExists("non-existent-file.txt") 20 | assert.Error(t, err, "should return an error") 21 | assert.Contains(t, err.Error(), "cannot access file", "error message should indicate file access issue") 22 | }) 23 | 24 | t.Run("should return an error if the path is a directory", func(t *testing.T) { 25 | dir := t.TempDir() 26 | err := FileExists(dir) 27 | assert.Error(t, err, "should return an error for a directory") 28 | assert.Contains(t, err.Error(), "is a directory", "error message should indicate it's a directory") 29 | }) 30 | 31 | t.Run("should return nil for an existing file", func(t *testing.T) { 32 | tempDir := t.TempDir() 33 | filePath := filepath.Join(tempDir, "test.txt") 34 | err := os.WriteFile(filePath, []byte("test"), 0644) 35 | assert.NoError(t, err, "should create temp file without error") 36 | 37 | err = FileExists(filePath) 38 | assert.NoError(t, err, "should not return an error for an existing file") 39 | }) 40 | } 41 | 42 | func TestDirExists(t *testing.T) { 43 | t.Run("should return an error for an empty directory path", func(t *testing.T) { 44 | err := DirExists("") 45 | assert.Error(t, err, "should return an error") 46 | assert.Equal(t, "error: directory path was empty or not provided", err.Error(), "error message should match") 47 | }) 48 | 49 | t.Run("should return an error for a non-existent directory", func(t *testing.T) { 50 | err := DirExists("non-existent-dir") 51 | assert.Error(t, err, "should return an error") 52 | assert.Contains(t, err.Error(), "cannot access directory", "error message should indicate directory access issue") 53 | }) 54 | 55 | t.Run("should return an error if the path is a file", func(t *testing.T) { 56 | tempDir := t.TempDir() 57 | filePath := filepath.Join(tempDir, "test.txt") 58 | err := os.WriteFile(filePath, []byte("test"), 0644) 59 | assert.NoError(t, err, "should create temp file without error") 60 | 61 | err = DirExists(filePath) 62 | assert.Error(t, err, "should return an error for a file") 63 | assert.Contains(t, err.Error(), "is a file", "error message should indicate it's a file") 64 | }) 65 | 66 | t.Run("should return nil for an existing directory", func(t *testing.T) { 67 | dir := t.TempDir() 68 | err := DirExists(dir) 69 | assert.NoError(t, err, "should not return an error for an existing directory") 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /helpers/assert_test.go: -------------------------------------------------------------------------------- 1 | package helpers_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/joseluisq/enve/helpers" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type mockTest struct { 11 | *testing.T 12 | failed bool 13 | } 14 | 15 | func (t *mockTest) Errorf(format string, args ...interface{}) { 16 | t.failed = true 17 | } 18 | 19 | func (t *mockTest) FailNow() { 20 | t.failed = true 21 | } 22 | 23 | func (t *mockTest) Fail() { 24 | t.failed = true 25 | } 26 | 27 | func TestElementsContain(t *testing.T) { 28 | tests := []struct { 29 | name string 30 | t *mockTest 31 | listA any 32 | listB any 33 | expected bool 34 | }{ 35 | { 36 | name: "should return true when listB is a subset of listA", 37 | listA: []int{1, 2, 3, 4}, 38 | listB: []int{2, 4}, 39 | expected: true, 40 | }, 41 | { 42 | name: "should return true when lists are identical", 43 | listA: []string{"a", "b", "c"}, 44 | listB: []string{"a", "b", "c"}, 45 | expected: true, 46 | }, 47 | { 48 | name: "should return true when listB is empty", 49 | listA: []int{1, 2, 3}, 50 | listB: []int{}, 51 | expected: true, 52 | }, 53 | { 54 | name: "should return true when both lists are empty", 55 | listA: []any{}, 56 | listB: []any{}, 57 | expected: true, 58 | }, 59 | { 60 | name: "should return true with duplicate elements", 61 | listA: []int{1, 2, 2, 3}, 62 | listB: []int{2, 2}, 63 | expected: true, 64 | }, 65 | { 66 | name: "should return false when listB has elements not in listA", 67 | listA: []int{1, 2, 3}, 68 | listB: []int{2, 5}, 69 | }, 70 | { 71 | name: "should return false when listB requires more duplicates than in listA", 72 | listA: []int{1, 2, 3}, 73 | listB: []int{2, 2}, 74 | }, 75 | { 76 | name: "should return false when listA is not a slice", 77 | listA: "not a slice", 78 | listB: []int{1}, 79 | }, 80 | { 81 | name: "should return false when listB is not a slice", 82 | listA: []int{1}, 83 | listB: map[int]int{1: 1}, 84 | }, 85 | { 86 | name: "should return false when listA is empty and listB is not", 87 | listB: []int{1}, 88 | }, 89 | { 90 | name: "should return false when inputs are not of type slice", 91 | listA: "something", 92 | listB: "something", 93 | }, 94 | } 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | mockTest := &mockTest{T: t} 98 | actual := helpers.ElementsContain(mockTest, tt.listA, tt.listB) 99 | 100 | assert.Equal(t, tt.expected, actual, "ElementsContain should return the expected result") 101 | 102 | // Check if the test failed when it was expected to 103 | if !tt.expected && !mockTest.failed { 104 | assert.Fail(t, "Expected a test failure, but it passed.") 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /cmd/exec_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package cmd 5 | 6 | import ( 7 | "bytes" 8 | "errors" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func Test_execCmd(t *testing.T) { 18 | basePath, err := filepath.Abs("../") 19 | assert.NoError(t, err) 20 | fixturesPath := filepath.Join(basePath, "fixtures", "cmd") 21 | bashFile := filepath.Join(fixturesPath, "test.sh") 22 | 23 | tests := []struct { 24 | name string 25 | tailArgs []string 26 | chdirPath string 27 | newEnv bool 28 | envVars []string 29 | setupEnv map[string]string 30 | expectedErr error 31 | expectedOutput string 32 | }{ 33 | { 34 | name: "should execute a command successfully", 35 | tailArgs: []string{"echo", "hello world"}, 36 | expectedOutput: "hello world\n", 37 | }, 38 | { 39 | name: "should return error for non-existent command", 40 | tailArgs: []string{"nonexistentcommand"}, 41 | expectedErr: errors.New("error: executable 'nonexistentcommand' was not found."), 42 | }, 43 | { 44 | name: "should execute command with existing environment variables", 45 | tailArgs: []string{bashFile}, 46 | expectedOutput: "" + 47 | "DB_PROTOCOL=udp\n" + 48 | "DB_HOST=127.0.0.1\n" + 49 | "DB_PORT=3306\n" + 50 | "DB_DEFAULT_CHARACTER_SET=utf8\n" + 51 | "DB_EXPORT_GZIP=true\n" + 52 | "DB_EXPORT_FILE_PATH=dbname.sql.gz\n" + 53 | "DB_NAME=dbname\n" + 54 | "DB_USERNAME=username\n" + 55 | "DB_PASSWORD=passwd\n" + 56 | "DB_ARGS=\n", 57 | }, 58 | { 59 | name: "should execute command with new environment variables", 60 | tailArgs: []string{bashFile}, 61 | newEnv: true, 62 | envVars: []string{"DB_PROTOCOL=tcp", "DB_HOST=localhost"}, 63 | expectedOutput: "" + 64 | "DB_PROTOCOL=tcp\n" + 65 | "DB_HOST=localhost\n" + 66 | "DB_PORT=\n" + 67 | "DB_DEFAULT_CHARACTER_SET=\n" + 68 | "DB_EXPORT_GZIP=\n" + 69 | "DB_EXPORT_FILE_PATH=\n" + 70 | "DB_NAME=\n" + 71 | "DB_USERNAME=\n" + 72 | "DB_PASSWORD=\n" + 73 | "DB_ARGS=\n", 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | for k, v := range tt.setupEnv { 80 | t.Setenv(k, v) 81 | } 82 | 83 | oldStdout := os.Stdout 84 | r, w, _ := os.Pipe() 85 | os.Stdout = w 86 | 87 | err := execCmd(tt.tailArgs, tt.chdirPath, tt.newEnv, tt.envVars) 88 | 89 | w.Close() 90 | os.Stdout = oldStdout 91 | 92 | var buf bytes.Buffer 93 | io.Copy(&buf, r) 94 | output := buf.String() 95 | 96 | if tt.expectedErr != nil { 97 | assert.Error(t, err, "expected an error but got none") 98 | assert.Contains(t, err.Error(), tt.expectedErr.Error(), "expected error message to match") 99 | } else { 100 | assert.NoError(t, err, "did not expect an error but got one") 101 | } 102 | 103 | assert.Equal(t, tt.expectedOutput, output, "output did not match expected") 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /cmd/cmd_win_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package cmd 5 | 6 | import ( 7 | "bytes" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestPlainEnv(t *testing.T) { 16 | expected := []string{ 17 | "DB_PROTOCOL=tcp", 18 | "DB_HOST=127.0.0.1", 19 | "DB_PORT=3306", 20 | "DB_DEFAULT_CHARACTER_SET=utf8", 21 | "DB_EXPORT_GZIP=true", 22 | "DB_EXPORT_FILE_PATH=dbname.sql.gz", 23 | "DB_NAME=dbname", 24 | "DB_USERNAME=username", 25 | "DB_PASSWORD=passwd", 26 | "DB_ARGS=", 27 | } 28 | 29 | basePath := path.Dir("./../") 30 | 31 | envFile := basePath + "/fixtures/cmd/devel.env" 32 | psFile := basePath + "/fixtures/cmd/test.ps1" 33 | 34 | cmd := exec.Command("go", "run", basePath+"/main.go", "-f", envFile, "powershell", "-ExecutionPolicy", "Bypass", "-File", psFile) 35 | 36 | var out bytes.Buffer 37 | cmd.Stderr = os.Stderr 38 | cmd.Stdin = os.Stdin 39 | cmd.Stdout = &out 40 | 41 | err := cmd.Run() 42 | 43 | if err != nil { 44 | t.Errorf("error trying to read the .env file.\n %s", err) 45 | } 46 | 47 | actual := strings.Split(out.String(), "\n") 48 | for i, exp := range expected { 49 | act := strings.TrimRight(actual[i], "\r") 50 | if exp != act { 51 | t.Errorf("actual: [%s] expected: [%s]", act, exp) 52 | } 53 | } 54 | } 55 | 56 | func TestOverwriteDisabledPlainEnv(t *testing.T) { 57 | expected := []string{ 58 | "DB_PROTOCOL=udp", 59 | "DB_HOST=127.0.0.1", 60 | "DB_PORT=3306", 61 | "DB_DEFAULT_CHARACTER_SET=utf8", 62 | "DB_EXPORT_GZIP=true", 63 | "DB_EXPORT_FILE_PATH=dbname.sql.gz", 64 | "DB_NAME=dbname", 65 | "DB_USERNAME=username", 66 | "DB_PASSWORD=passwd", 67 | "DB_ARGS=", 68 | } 69 | 70 | basePath := path.Dir("./../") 71 | 72 | envFile := basePath + "/fixtures/cmd/devel.env" 73 | psFile := basePath + "/fixtures/cmd/test.ps1" 74 | 75 | // Set DB_PROTOCOL as UDP before running the script 76 | os.Setenv("DB_PROTOCOL", "udp") 77 | 78 | cmd := exec.Command("go", "run", basePath+"/main.go", "-f", envFile, "powershell", "-ExecutionPolicy", "Bypass", "-File", psFile) 79 | 80 | var out bytes.Buffer 81 | cmd.Stderr = os.Stderr 82 | cmd.Stdin = os.Stdin 83 | cmd.Stdout = &out 84 | 85 | err := cmd.Run() 86 | 87 | if err != nil { 88 | t.Errorf("error trying to read the .env file.\n %s", err) 89 | } 90 | 91 | actual := strings.Split(out.String(), "\n") 92 | for i, exp := range expected { 93 | act := strings.TrimRight(actual[i], "\r") 94 | if exp != act { 95 | t.Errorf("actual: [%s] expected: [%s]", act, exp) 96 | } 97 | } 98 | } 99 | 100 | func TestOverwriteEnabledPlainEnv(t *testing.T) { 101 | expected := []string{ 102 | "DB_PROTOCOL=tcp", 103 | "DB_HOST=127.0.0.1", 104 | "DB_PORT=3306", 105 | "DB_DEFAULT_CHARACTER_SET=utf8", 106 | "DB_EXPORT_GZIP=true", 107 | "DB_EXPORT_FILE_PATH=dbname.sql.gz", 108 | "DB_NAME=dbname", 109 | "DB_USERNAME=username", 110 | "DB_PASSWORD=passwd", 111 | "DB_ARGS=", 112 | } 113 | 114 | basePath := path.Dir("./../") 115 | 116 | envFile := basePath + "/fixtures/cmd/devel.env" 117 | psFile := basePath + "/fixtures/cmd/test.ps1" 118 | 119 | // Set DB_PROTOCOL as UDP before running the script 120 | os.Setenv("DB_PROTOCOL", "udp") 121 | 122 | cmd := exec.Command("go", "run", basePath+"/main.go", "-w", "-f", envFile, "powershell", "-ExecutionPolicy", "Bypass", "-File", psFile) 123 | 124 | var out bytes.Buffer 125 | cmd.Stderr = os.Stderr 126 | cmd.Stdin = os.Stdin 127 | cmd.Stdout = &out 128 | 129 | err := cmd.Run() 130 | 131 | if err != nil { 132 | t.Errorf("error trying to read the .env file.\n %s", err) 133 | } 134 | 135 | actual := strings.Split(out.String(), "\n") 136 | for i, exp := range expected { 137 | act := strings.TrimRight(actual[i], "\r") 138 | if exp != act { 139 | t.Errorf("actual: [%s] expected: [%s]", act, exp) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /cmd/handler.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/joseluisq/cline/app" 8 | 9 | "github.com/joseluisq/enve/env" 10 | "github.com/joseluisq/enve/fs" 11 | ) 12 | 13 | func appHandler(ctx *app.AppContext) error { 14 | var flags = ctx.Flags() 15 | 16 | // ignore-environment option 17 | ignoreEnvF, err := flags.Bool("ignore-environment") 18 | if err != nil { 19 | return err 20 | } 21 | ignoreEnv, err := ignoreEnvF.Value() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | // no-file option 27 | noFileF, err := flags.Bool("no-file") 28 | if err != nil { 29 | return err 30 | } 31 | noFile, err := noFileF.Value() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // file option 37 | file, err := flags.String("file") 38 | if err != nil { 39 | return err 40 | } 41 | filePath := file.Value() 42 | 43 | // new-environment option 44 | newEnvF, err := flags.Bool("new-environment") 45 | if err != nil { 46 | return err 47 | } 48 | newEnv, err := newEnvF.Value() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | var envVars env.Slice 54 | 55 | // stdin option 56 | stdinF, err := flags.Bool("stdin") 57 | if err != nil { 58 | return err 59 | } 60 | stdin, err := stdinF.Value() 61 | if err != nil { 62 | return err 63 | } 64 | 65 | // overwrite option 66 | overwriteF, err := flags.Bool("overwrite") 67 | if err != nil { 68 | return err 69 | } 70 | overwrite, err := overwriteF.Value() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | output, err := flags.String("output") 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // chdir option 81 | chdirPath := "" 82 | chdir, err := flags.String("chdir") 83 | if err != nil { 84 | return err 85 | } 86 | if chdir.IsProvided() { 87 | chdirPath = chdir.Value() 88 | if err := fs.DirExists(chdirPath); err != nil { 89 | return err 90 | } 91 | if err := os.Chdir(chdirPath); err != nil { 92 | return fmt.Errorf("error: cannot change directory to '%s'.\n%v", chdirPath, err) 93 | } 94 | } 95 | 96 | if stdin { 97 | fi, err := os.Stdin.Stat() 98 | if err != nil { 99 | return fmt.Errorf("error: cannot read from stdin.\n%v", err) 100 | } 101 | if (fi.Mode() & os.ModeCharDevice) == 0 { 102 | envr := env.FromReader(os.Stdin) 103 | 104 | if ignoreEnv { 105 | goto ContinueEnvProc 106 | } 107 | 108 | if newEnv { 109 | vmap, err := envr.Parse() 110 | if err != nil { 111 | return err 112 | } 113 | envVars = vmap.Array() 114 | } else { 115 | if err := envr.Load(overwrite); err != nil { 116 | str := "" 117 | if overwrite { 118 | str = " (overwrite)" 119 | } 120 | return fmt.Errorf("error: cannot load env from stdin%s.\n%v", str, err) 121 | } 122 | envVars = env.Slice(os.Environ()) 123 | } 124 | 125 | goto ContinueEnvProc 126 | } 127 | } 128 | 129 | if !ignoreEnv { 130 | if noFile { 131 | if newEnv || ignoreEnv { 132 | goto ContinueEnvProc 133 | } 134 | 135 | envVars = env.Slice(os.Environ()) 136 | goto ContinueEnvProc 137 | } 138 | 139 | // .env file processing 140 | envf, err := env.FromPath(filePath) 141 | if err != nil { 142 | return err 143 | } 144 | defer envf.Close() 145 | 146 | if newEnv { 147 | vmap, err := envf.Parse() 148 | if err != nil { 149 | return err 150 | } 151 | envVars = vmap.Array() 152 | } else { 153 | if err := envf.Load(overwrite); err != nil { 154 | str := "" 155 | if overwrite { 156 | str = " (overwrite)" 157 | } 158 | return fmt.Errorf("error: cannot load env from file%s.\n%v", str, err) 159 | } 160 | 161 | envVars = env.Slice(os.Environ()) 162 | } 163 | } 164 | 165 | ContinueEnvProc: 166 | tailArgs := ctx.TailArgs() 167 | 168 | totalFags := len(flags.GetProvided()) 169 | noFlags := totalFags == 0 170 | hasTailArgs := len(tailArgs) > 0 171 | hasNoArgs := noFlags && !hasTailArgs 172 | 173 | if hasNoArgs { 174 | goto OutputEnvProc 175 | } 176 | 177 | // if tail args passed then execute the given command 178 | if hasTailArgs { 179 | if output.IsProvided() { 180 | return fmt.Errorf("error: output format cannot be used when executing a command") 181 | } 182 | 183 | return execCmd(tailArgs, chdirPath, newEnv, envVars) 184 | } 185 | 186 | OutputEnvProc: 187 | out := output.Value() 188 | switch out { 189 | case "text": 190 | fmt.Println(envVars.Text()) 191 | case "json": 192 | if buf, err := envVars.JSON(); err != nil { 193 | return err 194 | } else { 195 | fmt.Println(string(buf)) 196 | } 197 | case "xml": 198 | if buf, err := envVars.XML(); err != nil { 199 | return err 200 | } else { 201 | fmt.Println("" + string(buf)) 202 | } 203 | default: 204 | if out == "" { 205 | return fmt.Errorf("error: output format was empty or not provided") 206 | } 207 | return fmt.Errorf("error: output format '%s' is not supported", out) 208 | } 209 | 210 | return nil 211 | } 212 | -------------------------------------------------------------------------------- /cmd/cmd_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package cmd 5 | 6 | import ( 7 | "bytes" 8 | "errors" 9 | "fmt" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "strings" 14 | "testing" 15 | 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | func TestPlainEnv(t *testing.T) { 20 | expected := strings.Join([]string{ 21 | "DB_PROTOCOL=tcp", 22 | "DB_HOST=127.0.0.1", 23 | "DB_PORT=3306", 24 | "DB_DEFAULT_CHARACTER_SET=utf8", 25 | "DB_EXPORT_GZIP=true", 26 | "DB_EXPORT_FILE_PATH=dbname.sql.gz", 27 | "DB_NAME=dbname", 28 | "DB_USERNAME=username", 29 | "DB_PASSWORD=passwd", 30 | "DB_ARGS=", 31 | }, "\n") 32 | 33 | t.Run("should read .env file", func(t *testing.T) { 34 | basePath := path.Dir("./../") 35 | envFile := basePath + "/fixtures/cmd/devel.env" 36 | bashFile := basePath + "/fixtures/cmd/test.sh" 37 | 38 | cmd := exec.Command("go", "run", basePath+"/main.go", "-f", envFile, bashFile) 39 | 40 | var out bytes.Buffer 41 | cmd.Stderr = os.Stderr 42 | cmd.Stdin = os.Stdin 43 | cmd.Stdout = &out 44 | 45 | if err := cmd.Run(); err != nil { 46 | assert.Error(t, err, "error trying to read the .env file.") 47 | } 48 | 49 | actual := strings.Trim(out.String(), "\n") 50 | assert.Equal(t, expected, actual, "one or more env keys have wrong values") 51 | }) 52 | } 53 | 54 | func TestOverwriteDisabledPlainEnv(t *testing.T) { 55 | expected := strings.Join([]string{ 56 | "DB_PROTOCOL=udp", 57 | "DB_HOST=127.0.0.1", 58 | "DB_PORT=3306", 59 | "DB_DEFAULT_CHARACTER_SET=utf8", 60 | "DB_EXPORT_GZIP=true", 61 | "DB_EXPORT_FILE_PATH=dbname.sql.gz", 62 | "DB_NAME=dbname", 63 | "DB_USERNAME=username", 64 | "DB_PASSWORD=passwd", 65 | "DB_ARGS=", 66 | }, "\n") 67 | 68 | t.Run("should not overwrite env vars", func(t *testing.T) { 69 | basePath := path.Dir("./../") 70 | envFile := basePath + "/fixtures/cmd/devel.env" 71 | bashFile := basePath + "/fixtures/cmd/test.sh" 72 | 73 | // Set DB_PROTOCOL as UDP before running the script 74 | if err := os.Setenv("DB_PROTOCOL", "udp"); err != nil { 75 | assert.Error(t, err, "error setting DB_PROTOCOL environment variable") 76 | } 77 | 78 | cmd := exec.Command("go", "run", basePath+"/main.go", "-f", envFile, bashFile) 79 | 80 | var out bytes.Buffer 81 | cmd.Stderr = os.Stderr 82 | cmd.Stdin = os.Stdin 83 | cmd.Stdout = &out 84 | 85 | if err := cmd.Run(); err != nil { 86 | assert.Error(t, err, "error trying to read the .env file.") 87 | } 88 | 89 | actual := strings.Trim(out.String(), "\n") 90 | assert.Equal(t, expected, actual, "one or more env keys have wrong values") 91 | }) 92 | } 93 | 94 | func TestOverwriteEnabledPlainEnv(t *testing.T) { 95 | expected := strings.Join([]string{ 96 | "DB_PROTOCOL=tcp", 97 | "DB_HOST=127.0.0.1", 98 | "DB_PORT=3306", 99 | "DB_DEFAULT_CHARACTER_SET=utf8", 100 | "DB_EXPORT_GZIP=true", 101 | "DB_EXPORT_FILE_PATH=dbname.sql.gz", 102 | "DB_NAME=dbname", 103 | "DB_USERNAME=username", 104 | "DB_PASSWORD=passwd", 105 | "DB_ARGS=", 106 | }, "\n") 107 | 108 | t.Run("should overwrite env vars", func(t *testing.T) { 109 | basePath := path.Dir("./../") 110 | envFile := basePath + "/fixtures/cmd/devel.env" 111 | bashFile := basePath + "/fixtures/cmd/test.sh" 112 | 113 | // Set DB_PROTOCOL as UDP before running the script 114 | if err := os.Setenv("DB_PROTOCOL", "udp"); err != nil { 115 | assert.Error(t, err, "error setting DB_PROTOCOL environment variable") 116 | } 117 | 118 | cmd := exec.Command("go", "run", basePath+"/main.go", "-w", "-f", envFile, bashFile) 119 | 120 | var out bytes.Buffer 121 | cmd.Stderr = os.Stderr 122 | cmd.Stdin = os.Stdin 123 | cmd.Stdout = &out 124 | 125 | if err := cmd.Run(); err != nil { 126 | assert.Error(t, err, "error trying to read the .env file.") 127 | } 128 | 129 | actual := strings.Trim(out.String(), "\n") 130 | assert.Equal(t, expected, actual, "one or more env keys have wrong values") 131 | }) 132 | } 133 | 134 | const maxArgsCount = 1024 135 | 136 | func TestExecute(t *testing.T) { 137 | basePath := path.Dir("./../") 138 | envFile := basePath + "/fixtures/cmd/devel.env" 139 | 140 | tests := []struct { 141 | name string 142 | vargs []string 143 | expectedErr error 144 | }{ 145 | { 146 | name: "should return error for too many arguments", 147 | vargs: func() []string { 148 | // Create a slice with more arguments than the allowed maximum 149 | args := make([]string, maxArgsCount+2) 150 | args[0] = "app" 151 | for i := 1; i < len(args); i++ { 152 | args[i] = "arg" 153 | } 154 | return args 155 | }(), 156 | expectedErr: fmt.Errorf("error: number of arguments exceeds the limit of %d", maxArgsCount), 157 | }, 158 | { 159 | name: "should return error for non-existent file", 160 | expectedErr: errors.New("error: cannot access file '.env'.\nstat .env: no such file or directory"), 161 | }, 162 | { 163 | name: "should return error for non-existent command", 164 | vargs: []string{"app", "--file", envFile, "notfoundcmd"}, 165 | expectedErr: errors.New("error: executable 'notfoundcmd' was not found.\nexec: \"notfoundcmd\": executable file not found in $PATH"), 166 | }, 167 | { 168 | name: "should execute command successfully", 169 | vargs: []string{"app", "--no-file", "pwd"}, 170 | }, 171 | } 172 | for _, tt := range tests { 173 | t.Run(tt.name, func(t *testing.T) { 174 | if err := Execute(tt.vargs); tt.expectedErr != nil { 175 | assert.Error(t, err, "error was not expected but got one") 176 | assert.Equal(t, err.Error(), tt.expectedErr.Error(), "Error message does not match the expected one") 177 | } else { 178 | assert.NoError(t, err, "unexpected error but got none") 179 | } 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /env/slice_test.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSlice_Text(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | input Slice 15 | expected string 16 | }{ 17 | { 18 | name: "should return an empty string for an empty slice", 19 | }, 20 | { 21 | name: "should return a single line for a single element slice", 22 | input: Slice{"KEY=value"}, 23 | expected: "KEY=value", 24 | }, 25 | { 26 | name: "should join multiple elements with newlines", 27 | input: Slice{"KEY1=value1", "KEY2=value2", "KEY3=value3"}, 28 | expected: "KEY1=value1\nKEY2=value2\nKEY3=value3", 29 | }, 30 | { 31 | name: "should handle elements with no value", 32 | input: Slice{"KEY_ONLY", "KEY=value"}, 33 | expected: "KEY_ONLY\nKEY=value", 34 | }, 35 | } 36 | 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | assert.Equal(t, tt.expected, tt.input.Text(), "Text output should match") 40 | }) 41 | } 42 | } 43 | 44 | func TestSlice_Environ(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | input Slice 48 | expected Environment 49 | }{ 50 | { 51 | name: "should return an empty Environment for an empty slice", 52 | }, 53 | { 54 | name: "should correctly parse valid key-value pairs", 55 | input: Slice{"KEY1=value1", "KEY2=value2"}, 56 | expected: Environment{ 57 | Env: []EnvironmentVar{ 58 | {Name: "KEY1", Value: "value1"}, 59 | {Name: "KEY2", Value: "value2"}, 60 | }, 61 | }, 62 | }, 63 | { 64 | name: "should ignore invalid pairs (no equals sign)", 65 | input: Slice{"INVALID_KEY", "KEY=value"}, 66 | expected: Environment{ 67 | Env: []EnvironmentVar{ 68 | {Name: "KEY", Value: "value"}, 69 | }, 70 | }, 71 | }, 72 | { 73 | name: "should handle empty values", 74 | input: Slice{"EMPTY_VALUE=", "KEY=value"}, 75 | expected: Environment{ 76 | Env: []EnvironmentVar{ 77 | {Name: "EMPTY_VALUE", Value: ""}, 78 | {Name: "KEY", Value: "value"}, 79 | }, 80 | }, 81 | }, 82 | { 83 | name: "should handle values with equals signs", 84 | input: Slice{"URL=http://example.com?param=value", "KEY=value"}, 85 | expected: Environment{ 86 | Env: []EnvironmentVar{ 87 | {Name: "URL", Value: "http://example.com?param=value"}, 88 | {Name: "KEY", Value: "value"}, 89 | }, 90 | }, 91 | }, 92 | } 93 | 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | actual := tt.input.Environ() 97 | assert.Equal(t, tt.expected, actual, "Environ output should match") 98 | }) 99 | } 100 | } 101 | 102 | func TestSlice_JSON(t *testing.T) { 103 | tests := []struct { 104 | name string 105 | input Slice 106 | expected Environment 107 | expectedErr error 108 | }{ 109 | { 110 | name: "should return empty JSON array for an empty slice", 111 | }, 112 | { 113 | name: "should return correct JSON for valid key-value pairs", 114 | input: Slice{"KEY1=value1", "KEY2=value2"}, 115 | expected: Environment{ 116 | Env: []EnvironmentVar{ 117 | {Name: "KEY1", Value: "value1"}, 118 | {Name: "KEY2", Value: "value2"}, 119 | }, 120 | }, 121 | }, 122 | { 123 | name: "should ignore invalid pairs in JSON output", 124 | input: Slice{"INVALID_KEY", "VALID=value"}, 125 | expected: Environment{ 126 | Env: []EnvironmentVar{ 127 | {Name: "VALID", Value: "value"}, 128 | }, 129 | }, 130 | }, 131 | { 132 | name: "should return an error when parsing invalid JSON", 133 | input: Slice{"null"}, 134 | }, 135 | } 136 | 137 | for _, tt := range tests { 138 | t.Run(tt.name, func(t *testing.T) { 139 | actualJSON, err := tt.input.JSON() 140 | 141 | if tt.expectedErr != nil { 142 | assert.Error(t, err, "Expected an error but got none") 143 | assert.Equal(t, tt.expectedErr.Error(), err.Error(), "Error message mismatch") 144 | } else { 145 | assert.NoError(t, err, "Did not expect an error but got one") 146 | 147 | var actual Environment 148 | if err := json.Unmarshal(actualJSON, &actual); tt.expectedErr != nil { 149 | assert.Error(t, err, "Expected an error but got none") 150 | assert.Equal(t, err.Error(), tt.expectedErr.Error(), "Error message mismatch") 151 | } else { 152 | assert.Equal(t, tt.expected, actual, "JSON output should match") 153 | } 154 | } 155 | }) 156 | } 157 | } 158 | 159 | func TestSlice_XML(t *testing.T) { 160 | tests := []struct { 161 | name string 162 | input Slice 163 | expected Environment 164 | expectedErr error 165 | }{ 166 | { 167 | name: "should return empty XML array for an empty slice", 168 | }, 169 | { 170 | name: "should return correct XML for valid key-value pairs", 171 | input: Slice{"KEY1=value1", "KEY2=value2"}, 172 | expected: Environment{ 173 | Env: []EnvironmentVar{ 174 | {Name: "KEY1", Value: "value1"}, 175 | {Name: "KEY2", Value: "value2"}, 176 | }, 177 | }, 178 | }, 179 | { 180 | name: "should ignore invalid pairs in XML output", 181 | input: Slice{"INVALID_KEY", "VALID=value"}, 182 | expected: Environment{ 183 | Env: []EnvironmentVar{ 184 | {Name: "VALID", Value: "value"}, 185 | }, 186 | }, 187 | }, 188 | { 189 | name: "should return an error when parsing invalid XML", 190 | input: Slice{"null"}, 191 | }, 192 | } 193 | 194 | for _, tt := range tests { 195 | t.Run(tt.name, func(t *testing.T) { 196 | actualXML, err := tt.input.XML() 197 | 198 | if tt.expectedErr != nil { 199 | assert.Error(t, err, "Expected an error but got none") 200 | assert.Equal(t, tt.expectedErr.Error(), err.Error(), "Error message mismatch") 201 | } else { 202 | assert.NoError(t, err, "Did not expect an error but got one") 203 | 204 | var actual Environment 205 | if err := xml.Unmarshal(actualXML, &actual); tt.expectedErr != nil { 206 | assert.Error(t, err, "Expected an error but got none") 207 | assert.Equal(t, err.Error(), tt.expectedErr.Error(), "Error message mismatch") 208 | } else { 209 | assert.Equal(t, tt.expected, actual, "XML output should match") 210 | } 211 | } 212 | }) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Enve ![devel](https://github.com/joseluisq/enve/workflows/devel/badge.svg) [![codecov](https://codecov.io/gh/joseluisq/enve/graph/badge.svg?token=U77DXS42C6)](https://codecov.io/gh/joseluisq/enve) [![Go Report Card](https://goreportcard.com/badge/github.com/joseluisq/enve)](https://goreportcard.com/report/github.com/joseluisq/enve) 2 | 3 | > Run a program in a modified environment providing an optional `.env` file or variables from `stdin`. 4 | 5 | **Enve** is a cross-platform tool that can load environment variables from a [`.env` file](https://www.ibm.com/docs/en/aix/7.2?topic=files-env-file) or from standard input (stdin) and run a command with those variables set in the environment. 6 | 7 | It also allows you to output environment variables in `text`, `json` or `xml` format as well as to overwrite existing ones with values from a custom `.env` file or `stdin`. 8 | 9 | Enve can be considered as a counterpart of [GNU env](https://www.gnu.org/software/coreutils/manual/html_node/env-invocation.html) command. 10 | 11 | ## Install 12 | 13 | - **Platforms supported:** `linux`, `darwin`, `windows`, `freebsd`, `openbsd` 14 | - **Architectures supported:** `amd64`, `386`, `arm`, `arm64`, `ppc64le` 15 | 16 | ```sh 17 | curl -sSL \ 18 | "https://github.com/joseluisq/enve/releases/download/v1.5.1/enve_v1.5.1_linux_amd64.tar.gz" \ 19 | | sudo tar zxf - -C /usr/local/bin/ enve 20 | ``` 21 | 22 | Using Go: 23 | 24 | ```sh 25 | go install github.com/joseluisq/enve@latest 26 | ``` 27 | 28 | Pre-compiled binaries also available on [joseluisq/enve/releases](https://github.com/joseluisq/enve/releases) 29 | 30 | ## Usage 31 | 32 | By default, **enve** will print all environment variables like `env` command. 33 | 34 | ```sh 35 | enve 36 | # Or its equivalent 37 | enve --output text 38 | ``` 39 | 40 | ### Executing commands 41 | 42 | By default, an optional `.env` file can be loaded from the current working directory. 43 | 44 | ```sh 45 | enve test.sh 46 | ``` 47 | 48 | ## Options 49 | 50 | #### `-f, --file` 51 | 52 | Loads environment variables from a specific file path. 53 | By default, `enve` will look for a file named `.env` in the current directory. 54 | 55 | ```sh 56 | # Use a .env file (default) 57 | enve test.sh 58 | # Or specify a custom one 59 | enve --file dev.env test.sh 60 | ``` 61 | 62 | #### `-o, --output` 63 | 64 | Outputs all environment variables in a specified format. 65 | 66 | ```sh 67 | # Print environment variables 68 | enve -o text 69 | enve -o json 70 | enve -o xml 71 | 72 | # Or export them to a file 73 | enve -o text > config.txt 74 | enve -o xml > config.xml 75 | enve -o json > config.json 76 | ``` 77 | 78 | #### `-w, --overwrite` 79 | 80 | Overwrites existing environment variables with values from the `.env` file or stdin. 81 | 82 | ```sh 83 | # Overwrite via .env 84 | export API_URL="http://localhost:3000" 85 | enve --overwrite -f .env ./tests.sh 86 | 87 | # Or via stdin (which ignores .env file if present) 88 | echo -e "API_URL=http://127.0.0.1:4000" | enve --stdin -w -o text 89 | ``` 90 | 91 | #### `-c, --chdir` 92 | 93 | Changes the current working directory before executing the command. 94 | 95 | ```sh 96 | # Change working directory of a script 97 | enve --chdir /opt/my-app ./test.sh 98 | ``` 99 | 100 | #### `-n, --new-environment` 101 | 102 | Starts a new environment containing only variables from either a `.env` file or stdin. 103 | 104 | ```sh 105 | # Isolated the environment using only variables from .env 106 | enve --new-environment -f devel.env ./test.sh 107 | 108 | # Isolate the environment using only variables from stdin 109 | echo -e "APP_HOST=localhost\nAPP_PORT=8080" | enve --stdin -n test.sh 110 | ``` 111 | 112 | #### `-s, --stdin` 113 | 114 | Reads environment variables from the standard input (stdin) instead of a file. 115 | When using `--stdin`, the `.env` file is ignored. 116 | 117 | ```sh 118 | # Pipe environment variables from stdin and run a script 119 | cat development.env | enve --stdin ./my_script.sh 120 | echo -e "APP_HOST=127.0.0.1" | enve -s test.sh 121 | ``` 122 | 123 | #### `-i, --ignore-environment` 124 | 125 | Starts with an empty environment skipping any existing environment variables. 126 | 127 | ```sh 128 | # Run a script in a clean environment 129 | enve --ignore-environment my_script.sh 130 | 131 | echo -e "APP_HOST=127.0.0.1" | enve -i --stdin -o json 132 | # {"environment":[]} 133 | ``` 134 | 135 | #### `-z, --no-file` 136 | 137 | Prevents `enve` from loading any `.env` file, printing or running a command only with the existing environment. 138 | 139 | ```sh 140 | # Run a command without loading the default .env file 141 | enve --no-file my_app 142 | 143 | # Behaves like the standard 'env' command, printing the current environment 144 | enve -z 145 | ``` 146 | 147 | #### `-h, --help` 148 | 149 | ``` 150 | Run a program in a modified environment providing an optional .env file or variables from stdin 151 | 152 | USAGE: 153 | enve [OPTIONS] COMMAND 154 | 155 | OPTIONS: 156 | -f --file Load environment variables from a file path (optional) [default: .env] 157 | -o --output Output environment variables using text, json or xml format [default: text] 158 | -w --overwrite Overwrite environment variables if already set [default: false] 159 | -c --chdir Change currrent working directory 160 | -n --new-environment Start a new environment with only variables from the .env file or stdin [default: false] 161 | -i --ignore-environment Starts with an empty environment, ignoring any existing environment variables [default: false] 162 | -z --no-file Do not load a .env file [default: false] 163 | -s --stdin Read only environment variables from stdin and ignore the .env file [default: false] 164 | -h --help Prints help information 165 | -v --version Prints version information 166 | ``` 167 | 168 | ## Contributions 169 | 170 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in current work by you, as defined in the Apache-2.0 license, shall be dual licensed as described below, without any additional terms or conditions. 171 | 172 | Feel free to send some [Pull request](https://github.com/joseluisq/enve/pulls) or file an [issue](https://github.com/joseluisq/enve/issues). 173 | 174 | ## License 175 | 176 | This work is primarily distributed under the terms of both the [MIT license](LICENSE-MIT) and the [Apache License (Version 2.0)](LICENSE-APACHE). 177 | 178 | © 2020-present [Jose Quintana](https://joseluisq.net) 179 | -------------------------------------------------------------------------------- /env/env_test.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestFromReader(t *testing.T) { 15 | t.Run("should return an EnvReader from an io.Reader", func(t *testing.T) { 16 | reader := strings.NewReader("KEY=VALUE") 17 | envReader := FromReader(reader) 18 | assert.NotNil(t, envReader, "should not be nil") 19 | 20 | env, ok := envReader.(*Env) 21 | assert.True(t, ok, "should be of type *Env") 22 | assert.Equal(t, reader, env.r, "should contain the provided reader") 23 | }) 24 | } 25 | 26 | func TestFromPath(t *testing.T) { 27 | tests := []struct { 28 | name string 29 | expected func() (path string, data []byte, err error) 30 | createFile bool 31 | }{ 32 | { 33 | name: "should return an error for a non-existent file", 34 | expected: func() (path string, data []byte, err error) { 35 | path = "non-existent-file.env" 36 | err = fmt.Errorf("error: cannot access file '%s'.", path) 37 | return 38 | }, 39 | }, 40 | { 41 | name: "should return an error for a directory", 42 | expected: func() (path string, data []byte, err error) { 43 | path = t.TempDir() 44 | err = fmt.Errorf("error: file path '%s' is a directory", path) 45 | return 46 | }, 47 | }, 48 | { 49 | name: "should return an EnvFile for an existing file", 50 | expected: func() (path string, data []byte, err error) { 51 | path = filepath.Join(t.TempDir(), "test.env") 52 | data = []byte("KEY=VALUE") 53 | return 54 | }, 55 | createFile: true, 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | expectedPath, expectedData, expectedErr := tt.expected() 62 | 63 | if tt.createFile { 64 | if err := os.WriteFile(expectedPath, expectedData, 0644); err != nil { 65 | assert.NoError(t, err, "should write temp file without error") 66 | } 67 | } 68 | 69 | if envFile, err := FromPath(expectedPath); expectedErr != nil { 70 | assert.Error(t, err, "should return an error for a non-existent file") 71 | assert.Contains(t, err.Error(), expectedErr.Error(), "error message should indicate file access issue") 72 | assert.Nil(t, envFile, "should return a nil EnvFile") 73 | } else { 74 | assert.NoError(t, err, "should not return an error for an existing file") 75 | assert.NotNil(t, envFile, "should return a non-nil EnvFile") 76 | 77 | err = envFile.Close() 78 | assert.NoError(t, err, "should close the file without error") 79 | } 80 | }) 81 | } 82 | } 83 | 84 | func TestEnv_Parse(t *testing.T) { 85 | tests := []struct { 86 | name string 87 | input string 88 | expectedMap Map 89 | expectedErr bool 90 | }{ 91 | { 92 | name: "should parse valid key-value pairs", 93 | input: "KEY1=VALUE1\nKEY2=VALUE2", 94 | expectedMap: Map{"KEY1": "VALUE1", "KEY2": "VALUE2"}, 95 | }, 96 | { 97 | name: "should return an empty map for an empty reader", 98 | expectedMap: Map{}, 99 | }, 100 | { 101 | name: "should handle various valid formats", 102 | input: " KEY1 = VALUE1 #comment\nexport KEY2=VALUE2", 103 | expectedMap: Map{"KEY1": "VALUE1", "KEY2": "VALUE2"}, 104 | }, 105 | } 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | reader := strings.NewReader(tt.input) 110 | env := &Env{r: reader} 111 | envMap, err := env.Parse() 112 | 113 | if tt.expectedErr { 114 | assert.Error(t, err, "should return an error") 115 | } else { 116 | assert.NoError(t, err, "should not return an error") 117 | assert.Equal(t, tt.expectedMap, envMap, "parsed map should match expected") 118 | } 119 | }) 120 | } 121 | } 122 | 123 | func TestEnv_Load(t *testing.T) { 124 | t.Run("should load variables when overload is false", func(t *testing.T) { 125 | t.Setenv("EXISTING_KEY", "initial_value") 126 | 127 | reader := strings.NewReader("NEW_KEY=new_value\nEXISTING_KEY=new_value_overwritten") 128 | env := &Env{r: reader} 129 | err := env.Load(false) 130 | 131 | assert.NoError(t, err, "should load without error") 132 | assert.Equal(t, "new_value", os.Getenv("NEW_KEY"), "should set new environment variable") 133 | assert.Equal(t, "initial_value", os.Getenv("EXISTING_KEY"), "should not overwrite existing environment variable") 134 | }) 135 | 136 | t.Run("should load and overwrite variables when overload is true", func(t *testing.T) { 137 | t.Setenv("EXISTING_KEY", "initial_value") 138 | 139 | reader := strings.NewReader("NEW_KEY=new_value\nEXISTING_KEY=new_value_overwritten") 140 | env := &Env{r: reader} 141 | err := env.Load(true) 142 | 143 | assert.NoError(t, err, "should load without error") 144 | assert.Equal(t, "new_value", os.Getenv("NEW_KEY"), "should set new environment variable") 145 | assert.Equal(t, "new_value_overwritten", os.Getenv("EXISTING_KEY"), "should overwrite existing environment variable") 146 | }) 147 | 148 | t.Run("should return error on parse failure", func(t *testing.T) { 149 | reader := strings.NewReader("INVALID-INPUT") 150 | env := &Env{r: reader} 151 | err := env.Load(false) 152 | 153 | assert.Error(t, err, "should return an error on parse failure") 154 | }) 155 | } 156 | 157 | type errorReader struct{} 158 | 159 | func (e *errorReader) Read(p []byte) (n int, err error) { 160 | return 0, errors.New("read error") 161 | } 162 | 163 | func TestEnv_Parse_Error(t *testing.T) { 164 | t.Run("should return error when reader fails", func(t *testing.T) { 165 | env := &Env{r: &errorReader{}} 166 | _, err := env.Parse() 167 | assert.Error(t, err, "should return an error if reading fails") 168 | }) 169 | } 170 | 171 | func TestEnv_Close(t *testing.T) { 172 | t.Run("should close the file if it's an os.File", func(t *testing.T) { 173 | tempDir := t.TempDir() 174 | filePath := filepath.Join(tempDir, "test.env") 175 | err := os.WriteFile(filePath, []byte("KEY=VALUE"), 0644) 176 | assert.NoError(t, err) 177 | 178 | file, err := os.Open(filePath) 179 | assert.NoError(t, err) 180 | 181 | env := &Env{r: file} 182 | err = env.Close() 183 | assert.NoError(t, err, "should close the file without error") 184 | 185 | _, err = file.Read(make([]byte, 1)) 186 | assert.Error(t, err, "should be an error reading from a closed file") 187 | assert.True(t, env.closed, "closed flag should be true") 188 | }) 189 | 190 | t.Run("should not return an error if reader is not an os.File", func(t *testing.T) { 191 | reader := strings.NewReader("KEY=VALUE") 192 | env := &Env{r: reader} 193 | err := env.Close() 194 | assert.NoError(t, err, "should not return an error for non-file readers") 195 | assert.False(t, env.closed, "closed flag should be false") 196 | }) 197 | 198 | t.Run("should do nothing on second close", func(t *testing.T) { 199 | tempDir := t.TempDir() 200 | filePath := filepath.Join(tempDir, "test.env") 201 | err := os.WriteFile(filePath, []byte("KEY=VALUE"), 0644) 202 | assert.NoError(t, err) 203 | 204 | file, err := os.Open(filePath) 205 | assert.NoError(t, err) 206 | 207 | env := &Env{r: file} 208 | err = env.Close() 209 | assert.NoError(t, err, "first close should be successful") 210 | assert.True(t, env.closed, "closed flag should be true after first close") 211 | 212 | err = env.Close() 213 | assert.NoError(t, err, "second close should also be successful (no-op)") 214 | assert.True(t, env.closed, "closed flag should remain true") 215 | }) 216 | 217 | t.Run("should not return an error if reader is nil", func(t *testing.T) { 218 | env := &Env{r: nil} 219 | err := env.Close() 220 | assert.NoError(t, err, "should not return an error for a nil reader") 221 | }) 222 | } 223 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /cmd/handler_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "encoding/xml" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path/filepath" 12 | "slices" 13 | "strings" 14 | "testing" 15 | 16 | "github.com/stretchr/testify/assert" 17 | 18 | "github.com/joseluisq/cline/app" 19 | "github.com/joseluisq/cline/handler" 20 | "github.com/joseluisq/enve/env" 21 | "github.com/joseluisq/enve/helpers" 22 | ) 23 | 24 | const validEnvFile = "valid.env" 25 | const invalidEnvFile = "invalid.env" 26 | 27 | func TestAppHandler_Output(t *testing.T) { 28 | CWD, err := os.Getwd() 29 | if err != nil { 30 | assert.Fail(t, "Failed to get current working directory for tests", err) 31 | } 32 | 33 | var baseDirPath = filepath.Join(CWD, "../") 34 | var fixturePath = filepath.Join(baseDirPath, "fixtures", "handler") 35 | 36 | var newArgs = func(args []string) []string { 37 | return append([]string{"enve-test"}, args...) 38 | } 39 | 40 | var newArgsWithFile = func(filename string, args []string) []string { 41 | return newArgs(append( 42 | []string{"-f", filepath.Join(fixturePath, filename)}, 43 | args..., 44 | )) 45 | } 46 | 47 | var newArgsDefaultInvalid = func(args []string) []string { 48 | return newArgsWithFile(invalidEnvFile, args) 49 | } 50 | 51 | var newArgsDefault = func(args []string) []string { 52 | return newArgsWithFile(validEnvFile, args) 53 | } 54 | 55 | tests := []struct { 56 | // Input 57 | name string 58 | args []string 59 | expectedStdin []byte 60 | initialEnvs []string 61 | 62 | // Output 63 | expectedText []string // []string{"HOST=127.0.0.1"} 64 | expectedJSON *env.Environment 65 | expectedXML *env.Environment 66 | expectedErr error 67 | }{ 68 | { 69 | name: "should output nothing with no args provided", 70 | args: newArgsDefault([]string{}), 71 | expectedText: []string{""}, 72 | }, 73 | { 74 | name: "should output help with available flags", 75 | args: newArgsDefault([]string{"--help"}), 76 | expectedText: []string{ 77 | "enve-test", 78 | "Run a program in a modified environment", 79 | "v1.0.0-beta.1", 80 | "-f --file", 81 | "-o --output", 82 | "-w --overwrite", 83 | "-c --chdir", 84 | "-n --new-environment", 85 | "-i --ignore-environment", 86 | "-z --no-file", 87 | "-s --stdin", 88 | "-h --help", 89 | "-v --version", 90 | }, 91 | }, 92 | { 93 | name: "should output variables as text", 94 | args: newArgsDefault([]string{"--output", "text"}), 95 | initialEnvs: []string{ 96 | "API_URL=http://localhost:3000", 97 | }, 98 | expectedText: []string{ 99 | "API_URL=http://localhost:3000", 100 | "HOST=127.0.0.1", 101 | "PORT=8080", 102 | "DEBUG=true", 103 | "LOG_LEVEL=info", 104 | }, 105 | }, 106 | { 107 | name: "should output variables as json", 108 | args: newArgsDefault([]string{"--output", "json"}), 109 | initialEnvs: []string{ 110 | "SERVER_IP=192.168.1.1", 111 | }, 112 | expectedJSON: &env.Environment{ 113 | Env: []env.EnvironmentVar{ 114 | {Name: "SERVER_IP", Value: "192.168.1.1"}, 115 | {Name: "HOST", Value: "127.0.0.1"}, 116 | {Name: "PORT", Value: "8080"}, 117 | {Name: "DEBUG", Value: "true"}, 118 | {Name: "LOG_LEVEL", Value: "info"}, 119 | }, 120 | }, 121 | }, 122 | { 123 | name: "should output variables as xml", 124 | args: newArgsDefault([]string{"--output", "xml"}), 125 | initialEnvs: []string{ 126 | "SERVER2_IP=192.168.1.1", 127 | }, 128 | expectedXML: &env.Environment{ 129 | Env: []env.EnvironmentVar{ 130 | {Name: "SERVER2_IP", Value: "192.168.1.1"}, 131 | {Name: "HOST", Value: "127.0.0.1"}, 132 | {Name: "PORT", Value: "8080"}, 133 | {Name: "DEBUG", Value: "true"}, 134 | {Name: "LOG_LEVEL", Value: "info"}, 135 | }, 136 | }, 137 | }, 138 | { 139 | name: "should output variables with --new-environment as text", 140 | args: newArgsDefault([]string{"--new-environment"}), 141 | expectedText: []string{""}, 142 | }, 143 | { 144 | name: "should output variables with --new-environment as json", 145 | args: newArgsDefault([]string{"--new-environment", "--output", "json"}), 146 | expectedJSON: &env.Environment{ 147 | Env: []env.EnvironmentVar{ 148 | {Name: "HOST", Value: "127.0.0.1"}, 149 | {Name: "PORT", Value: "8080"}, 150 | {Name: "DEBUG", Value: "true"}, 151 | {Name: "LOG_LEVEL", Value: "info"}, 152 | }, 153 | }, 154 | }, 155 | { 156 | name: "should output variables with --new-environment as xml", 157 | args: newArgsDefault([]string{"--new-environment", "--output", "xml"}), 158 | expectedXML: &env.Environment{ 159 | Env: []env.EnvironmentVar{ 160 | {Name: "HOST", Value: "127.0.0.1"}, 161 | {Name: "PORT", Value: "8080"}, 162 | {Name: "DEBUG", Value: "true"}, 163 | {Name: "LOG_LEVEL", Value: "info"}, 164 | }, 165 | }, 166 | }, 167 | { 168 | name: "should output variables with --no-file as text", 169 | args: newArgsDefault([]string{"--no-file", "--output", "text"}), 170 | initialEnvs: []string{ 171 | "HOST=0.0.0.0", 172 | }, 173 | expectedText: []string{"HOST=0.0.0.0"}, 174 | }, 175 | { 176 | name: "should output variables with --no-file as json", 177 | args: newArgsDefault([]string{"--no-file", "--new-environment", "--output", "json"}), 178 | expectedJSON: &env.Environment{ 179 | Env: []env.EnvironmentVar{}, 180 | }, 181 | }, 182 | { 183 | name: "should output variables with --no-file as xml", 184 | args: newArgsDefault([]string{"--no-file", "--ignore-environment", "--output", "xml"}), 185 | expectedXML: &env.Environment{ 186 | Env: []env.EnvironmentVar{}, 187 | }, 188 | }, 189 | { 190 | name: "should overwrite variables and output as text", 191 | args: newArgsDefault([]string{"--overwrite", "--output", "text"}), 192 | initialEnvs: []string{ 193 | "HOST=192.168.1.1", 194 | }, 195 | expectedText: []string{"HOST=127.0.0.1"}, 196 | }, 197 | { 198 | name: "should overwrite variables and output as xml", 199 | args: newArgsDefault([]string{"--overwrite", "--output", "xml"}), 200 | initialEnvs: []string{ 201 | "HOST=192.168.1.1", 202 | }, 203 | expectedXML: &env.Environment{ 204 | Env: []env.EnvironmentVar{ 205 | {Name: "HOST", Value: "127.0.0.1"}, 206 | }, 207 | }, 208 | }, 209 | { 210 | name: "should overwrite variables and output as json", 211 | args: newArgsDefault([]string{"--overwrite", "--output", "json"}), 212 | initialEnvs: []string{ 213 | "LOG_LEVEL=error", 214 | }, 215 | expectedJSON: &env.Environment{ 216 | Env: []env.EnvironmentVar{ 217 | {Name: "LOG_LEVEL", Value: "info"}, 218 | }, 219 | }, 220 | }, 221 | { 222 | name: "should return error if env file does not exist in new working dir", 223 | args: newArgs([]string{"--chdir", "./cmd", "--output", "text"}), 224 | expectedErr: errors.New("error: cannot access directory './cmd'."), 225 | }, 226 | { 227 | name: "should output variables if env file exist in new working dir", 228 | args: newArgs([]string{"--chdir", fixturePath}), 229 | expectedText: []string{ 230 | "SERVER=localhost", 231 | "IP=192.168.1.120", 232 | "LEVEL=info", 233 | }, 234 | }, 235 | { 236 | name: "should output variables as xml if env file exist in new working dir", 237 | args: newArgs([]string{"--chdir", fixturePath, "-o", "xml"}), 238 | expectedXML: &env.Environment{ 239 | Env: []env.EnvironmentVar{ 240 | {Name: "SERVER", Value: "localhost"}, 241 | {Name: "IP", Value: "192.168.1.120"}, 242 | {Name: "LEVEL", Value: "info"}, 243 | }, 244 | }, 245 | }, 246 | { 247 | name: "should output variables as json if env file exist in new working dir", 248 | args: newArgs([]string{"--chdir", fixturePath, "-o", "json"}), 249 | expectedJSON: &env.Environment{ 250 | Env: []env.EnvironmentVar{ 251 | {Name: "SERVER", Value: "localhost"}, 252 | {Name: "IP", Value: "192.168.1.120"}, 253 | {Name: "LEVEL", Value: "info"}, 254 | }, 255 | }, 256 | }, 257 | { 258 | name: "should return error if env file does not exist", 259 | args: newArgs([]string{"--file", fixturePath + "-xyz", "-o", "json"}), 260 | expectedErr: fmt.Errorf("error: cannot access file '%s-xyz'.", fixturePath), 261 | }, 262 | { 263 | name: "should return error if env file cannot be parsed", 264 | args: newArgsDefaultInvalid([]string{}), 265 | expectedErr: errors.New("error: cannot load env from file."), 266 | }, 267 | { 268 | name: "should return error if env file cannot be parsed with overwrite", 269 | args: newArgsDefaultInvalid([]string{"--overwrite"}), 270 | expectedErr: errors.New("error: cannot load env from file (overwrite)."), 271 | }, 272 | { 273 | name: "should output variables as text when using stdin without initial ones", 274 | args: newArgs([]string{"--stdin"}), 275 | expectedStdin: []byte( 276 | "SERVER=localhost\nIP=192.168.1.120\nLEVEL=info\nAPP_URL=https://localhost", 277 | ), 278 | expectedText: []string{ 279 | "SERVER=localhost", 280 | "IP=192.168.1.120", 281 | "LEVEL=info", 282 | "APP_URL=https://localhost", 283 | }, 284 | }, 285 | { 286 | name: "should output variables as text when using stdin with initial ones", 287 | args: newArgs([]string{"--stdin"}), 288 | initialEnvs: []string{ 289 | "SERVER=127.0.0.1", 290 | }, 291 | expectedStdin: []byte( 292 | "SERVER=localhost\nIP=192.168.1.120\nLEVEL=info\nAPP_URL=https://localhost", 293 | ), 294 | expectedText: []string{ 295 | "SERVER=127.0.0.1", 296 | "IP=192.168.1.120", 297 | "LEVEL=info", 298 | "APP_URL=https://localhost", 299 | }, 300 | }, 301 | { 302 | name: "should return error when invalid using stdin", 303 | args: newArgs([]string{"--stdin"}), 304 | expectedStdin: []byte("\x00"), 305 | expectedErr: errors.New("error: cannot load env from stdin.\nunexpected character \"\\x00\" in variable name near \"\\x00\""), 306 | }, 307 | { 308 | name: "should return error when invalid using stdin with overwrite", 309 | args: newArgs([]string{"--stdin", "--overwrite"}), 310 | expectedStdin: []byte("\x00"), 311 | expectedErr: errors.New("error: cannot load env from stdin (overwrite).\nunexpected character \"\\x00\" in variable name near \"\\x00\""), 312 | }, 313 | { 314 | name: "should output overwritten variables as json when using stdin", 315 | args: newArgs([]string{"--stdin", "--overwrite", "-o", "json"}), 316 | expectedStdin: []byte( 317 | "NAME=User\nEMAIL=user@example.com\nAGE=30", 318 | ), 319 | expectedJSON: &env.Environment{ 320 | Env: []env.EnvironmentVar{ 321 | {Name: "NAME", Value: "User"}, 322 | {Name: "EMAIL", Value: "user@example.com"}, 323 | {Name: "AGE", Value: "30"}, 324 | }, 325 | }, 326 | }, 327 | { 328 | name: "should output overwritten variables as xml when using stdin", 329 | args: newArgs([]string{"--stdin", "--overwrite", "-o", "xml"}), 330 | expectedStdin: []byte( 331 | "NAME=Gopher\nEMAIL=ghoper@example.com\nAGE=100", 332 | ), 333 | expectedXML: &env.Environment{ 334 | Env: []env.EnvironmentVar{ 335 | {Name: "NAME", Value: "Gopher"}, 336 | {Name: "EMAIL", Value: "ghoper@example.com"}, 337 | {Name: "AGE", Value: "100"}, 338 | }, 339 | }, 340 | }, 341 | { 342 | name: "should output variables as text when using stdin with new environment", 343 | args: newArgs([]string{"--stdin", "--new-environment"}), 344 | initialEnvs: []string{ 345 | "SERVER=127.0.0.1", 346 | }, 347 | expectedStdin: []byte( 348 | "SERVER=localhost\nIP=192.168.1.120\nLEVEL=info\nAPP_URL=https://localhost", 349 | ), 350 | expectedText: []string{ 351 | "SERVER=localhost", 352 | "IP=192.168.1.120", 353 | "LEVEL=info", 354 | "APP_URL=https://localhost", 355 | }, 356 | }, 357 | { 358 | name: "should output variables as text when using stdin with new environment and overwrite", 359 | args: newArgs([]string{"--stdin", "--new-environment", "--overwrite", "--ignore-environment"}), 360 | expectedStdin: []byte( 361 | "IP=192.168.1.120\nLEVEL=info\nAPP_URL=https://localhost", 362 | ), 363 | }, 364 | { 365 | name: "should return error when invalid using stdin with new environment", 366 | args: newArgs([]string{"--stdin", "--new-environment", "-o", "json"}), 367 | initialEnvs: []string{ 368 | "SERVER=127.0.0.1", 369 | }, 370 | expectedStdin: []byte("\x00"), 371 | expectedErr: errors.New("unexpected character \"\\x00\" in variable name near \"\\x00\""), 372 | }, 373 | { 374 | name: "should return error when invalid new environment parsing", 375 | args: newArgsDefaultInvalid([]string{"--new-environment", "-o", "json"}), 376 | expectedErr: errors.New("unexpected character \"{\" in variable name near \""), 377 | }, 378 | { 379 | name: "should return an error invalid output format", 380 | args: newArgs([]string{"--output", "xyz"}), 381 | expectedErr: errors.New("error: output format 'xyz' is not supported"), 382 | }, 383 | { 384 | name: "should return an error empty output value", 385 | args: newArgs([]string{"--output", ""}), 386 | expectedErr: errors.New("error: output format was empty or not provided"), 387 | }, 388 | { 389 | name: "should return an error when using output with tail command", 390 | args: newArgs([]string{"--output", "text", "echo", "hello"}), 391 | expectedErr: errors.New("error: output format cannot be used when executing a command"), 392 | }, 393 | { 394 | name: "should return empty when has no args", 395 | args: newArgs([]string{}), 396 | expectedText: []string{}, 397 | }, 398 | } 399 | 400 | for _, tt := range tests { 401 | t.Run(tt.name, func(t *testing.T) { 402 | // Reset working directory for tests that will change it 403 | if slices.Contains(tt.args, "--chdir") || slices.Contains(tt.args, "-c") { 404 | if err := os.Chdir(CWD); err != nil { 405 | assert.Fail(t, "Failed to reset working directory before test: %v", err) 406 | } 407 | } 408 | 409 | // Setup app 410 | ap := app.New() 411 | ap.Name = "enve-test" 412 | ap.Summary = "Run a program in a modified environment" 413 | ap.Version = "v1.0.0-beta.1" 414 | ap.Flags = Flags 415 | ap.Handler = appHandler 416 | 417 | if tt.initialEnvs != nil { 418 | for _, envVar := range tt.initialEnvs { 419 | parts := strings.SplitN(envVar, "=", 2) 420 | if len(parts) == 2 { 421 | t.Setenv(parts[0], parts[1]) 422 | } else { 423 | assert.Fail(t, "Invalid environment variable format", envVar) 424 | } 425 | } 426 | } 427 | 428 | // Capture stdin 429 | if tt.expectedStdin != nil { 430 | oldStdin := os.Stdin 431 | r1, w1, err := os.Pipe() 432 | if err != nil { 433 | assert.Fail(t, "Failed to create pipe for stdin", err) 434 | } 435 | os.Stdin = r1 436 | 437 | defer func() { os.Stdin = oldStdin }() 438 | 439 | if _, err := w1.Write(tt.expectedStdin); err != nil { 440 | assert.Fail(t, "Failed to write to stdin pipe", err) 441 | } 442 | if err := w1.Close(); err != nil { 443 | assert.Fail(t, "Failed to write to stdin pipe", err) 444 | } 445 | } 446 | 447 | // Capture stdout 448 | oldStdout := os.Stdout 449 | r, w, err := os.Pipe() 450 | if err != nil { 451 | assert.Fail(t, "Failed to create pipe for stdout capture", err) 452 | } 453 | os.Stdout = w 454 | 455 | // Ensure stdout is restored even if the test panics 456 | defer func() { os.Stdout = oldStdout }() 457 | 458 | var outCopiedChan = make(chan struct{}) 459 | var buf bytes.Buffer 460 | 461 | go func() { 462 | defer close(outCopiedChan) 463 | // NOTE: `io.Copy` will block here until the writer (w) is closed 464 | _, err := io.Copy(&buf, r) 465 | assert.NoError(t, err, "Failed to copy output from pipe reader") 466 | }() 467 | 468 | t.Logf(" Running app as '%v'", strings.Join(tt.args, " ")) 469 | runErr := handler.New(ap).Run(tt.args) 470 | 471 | // Close the pipe's writer end to unblock the `io.Copy` in the goroutine above 472 | _ = w.Close() 473 | <-outCopiedChan 474 | 475 | output := buf.Bytes() 476 | 477 | if tt.expectedErr != nil { 478 | assert.Error(t, runErr, "Expected error but got none") 479 | assert.Contains(t, runErr.Error(), tt.expectedErr.Error(), "Error message mismatch") 480 | } else { 481 | assert.NoError(t, runErr, "Expected no error but got: %v", runErr) 482 | } 483 | 484 | if tt.expectedJSON != nil { 485 | var vars env.Environment 486 | if err := json.Unmarshal(output, &vars); err != nil { 487 | assert.Fail(t, "Failed to unmarshal JSON output", err) 488 | } 489 | 490 | helpers.ElementsContain( 491 | t, vars.Env, tt.expectedJSON.Env, "JSON output should match to %v", tt.expectedJSON, 492 | ) 493 | } 494 | 495 | if tt.expectedXML != nil { 496 | var vars env.Environment 497 | if err := xml.Unmarshal(output, &vars); err != nil { 498 | assert.Fail(t, "Failed to unmarshal XML output", err) 499 | } 500 | helpers.ElementsContain( 501 | t, vars.Env, tt.expectedXML.Env, "XML output should match to %v", tt.expectedXML, 502 | ) 503 | } 504 | 505 | for _, s := range tt.expectedText { 506 | assert.Contains(t, string(output), s, "Text output should contain %q", s) 507 | } 508 | }) 509 | } 510 | } 511 | --------------------------------------------------------------------------------