diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 55c34b1..b9f13cf 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.19 + go-version: 1.21 - name: Generate token id: generate_token uses: tibdex/github-app-token@v1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6403237..27944ca 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,7 +1,6 @@ name: test on: - push: pull_request: branches: [ main ] @@ -16,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.19 + go-version: 1.21 - name: Run go mod tidy run: go mod tidy - name: Run Test diff --git a/cmd/add.go b/cmd/add.go index 64374eb..2ca485f 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -53,7 +53,7 @@ func addCommand() *cobra.Command { name := args[0] profile := config.Profile{ Desc: flags.desc, - Env: []config.Env{}, + Env: []*config.Env{}, } profile.Env = config.ParseEnvFlagToEnv(flags.env) diff --git a/go.mod b/go.mod index 395200b..0c75d4b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/sunggun-yu/envp -go 1.19 +go 1.21 require ( github.com/fatih/color v1.15.0 diff --git a/go.sum b/go.sum index d118682..a1081f0 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,7 @@ github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbV github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= @@ -50,6 +51,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -62,6 +64,7 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 997fe15..7840ee3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,6 @@ package config import ( - "sort" - "strings" "sync" ) @@ -105,71 +103,3 @@ func (c *Config) ProfileNames() []string { defer c.mu.Unlock() return c.Profiles.ProfileNames() } - -// ParseEnvFlagToMap parse string format "env=val" to map "env: val". it can be used fo dup check from slice of Env -func ParseEnvFlagToMap(envs []string) map[string]string { - - if len(envs) == 0 { - return nil - } - - r := map[string]string{} - - for _, s := range envs { - ev := strings.Split(s, "=") - if len(ev) != 2 { - // TODO: handle unexpected format - continue - } else { - r[ev[0]] = ev[1] - } - } - return r -} - -// ParseEnvFlagToEnv parse slice of string "var=val" to []ENV -func ParseEnvFlagToEnv(args []string) Envs { - if len(args) == 0 { - return nil - } - - r := []Env{} - - for _, s := range args { - ev := strings.Split(s, "=") - if len(ev) != 2 { - // TODO: handle unexpected format - //fmt.Println("WARN: wrong format of env item. it must be var=val.", ev, "will be ignored") - continue - } else { - r = append(r, Env{ - Name: ev[0], - Value: ev[1], - }) - } - } - SortEnv(r) - return r -} - -// MapToEnv parse string map to slice of Env -func MapToEnv(m map[string]string) Envs { - r := []Env{} - for k, v := range m { - r = append(r, Env{ - Name: k, - Value: v, - }) - } - // sort it by env name - SortEnv(r) - return r -} - -// SortEnv sort []Env by name asc -func SortEnv(e []Env) { - // sort it by env name - sort.Slice(e, func(i, j int) bool { - return e[i].Name < e[j].Name - }) -} diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 2bbda56..999970d 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "io/ioutil" "os" "path/filepath" "strconv" @@ -14,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" ) -// perform test whithin single process but multi thread operation +// perform test within single process but multi thread operation func TestConfigFile(t *testing.T) { // assert @@ -111,7 +110,7 @@ func TestRead(t *testing.T) { - wrong - 1 ` - ioutil.WriteFile(testFile, []byte(wrongData), 0600) + os.WriteFile(testFile, []byte(wrongData), 0600) cf.config = nil _, err := cf.Read() @@ -147,7 +146,7 @@ func TestWrite(t *testing.T) { // Ginkgo test suite // --------------------------------------------------------------------------- var _ = Describe("NewConfigFile", func() { - When("set exisiting directory as config file", func() { + When("set existing directory as config file", func() { testFile := fmt.Sprintf("/tmp/%v/%v", GinkgoRandomSeed(), GinkgoRandomSeed()) testDir := filepath.Dir(testFile) os.Create(testDir) @@ -186,7 +185,7 @@ var _ = Describe("NewConfigFile", func() { testFile := "../../testdata/config.yaml" // copy test config file - original, _ := ioutil.ReadFile("../../testdata/config.yaml") + original, _ := os.ReadFile("../../testdata/config.yaml") _, err := NewConfigFile(testFile) @@ -195,7 +194,7 @@ var _ = Describe("NewConfigFile", func() { }) It("should not change existing config content", func() { - actual, err := ioutil.ReadFile(testFile) + actual, err := os.ReadFile(testFile) Expect(err).NotTo(HaveOccurred()) Expect(actual).To(Equal(original)) }) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d1461f7..f9f6e14 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,8 +2,7 @@ package config_test import ( "fmt" - "io/ioutil" - "reflect" + "os" "testing" "github.com/sunggun-yu/envp/internal/config" @@ -11,33 +10,8 @@ import ( ) var ( - testDataEnvs = config.Envs{ - config.Env{Name: "VAR_C", Value: "VAL_C"}, - config.Env{Name: "VAR_A", Value: "VAL_A"}, - config.Env{Name: "VAR_D", Value: "VAL_D"}, - config.Env{Name: "VAR_B", Value: "VAL_B"}, - } - - testDataEnvMap = map[string]string{ - "VAR_A": "VAL_A", - "VAR_B": "VAL_B", - "VAR_C": "VAL_C", - "VAR_D": "VAL_D", - } - - testDataArrStringFromFlag = []string{ - "VAR_A=VAL_A", - "something_not_valid", // should be ignored - "VAR_B=VAL_B", - "VAR_C=VAL_C", - "VAR_D=VAL_D", - "not:valid", // should be ignored - " ", // should be ignored - "how=about=this", // should be ignored - } - testDataConfig = func() config.Config { - file, _ := ioutil.ReadFile("../../testdata/config.yaml") + file, _ := os.ReadFile("../../testdata/config.yaml") var cfg config.Config err := yaml.Unmarshal(file, &cfg) @@ -48,79 +22,6 @@ var ( } ) -// test String() method and SortEnv -func TestEnv(t *testing.T) { - envs := testDataEnvs - // sort - config.SortEnv(envs) - - // data must be sorted in key - // Env should return string in VAR=VAL format - // Envs should return comma separated string - expected := "VAR_A=VAL_A,VAR_B=VAL_B,VAR_C=VAL_C,VAR_D=VAL_D" - actual := envs.String() - if expected != actual { - t.Error("Not meet expectation", expected, "-", actual) - } -} - -// test ParseEnvFlagToMap func -// ParseEnvFlagToMap should parse string format "env=val" to map "env: val" -func TestParseEnvFlagToMap(t *testing.T) { - - t.Run("when set empty data", func(t *testing.T) { - // nil data test - if config.ParseEnvFlagToMap([]string{}) != nil { - t.Error("Not meet expectation. empty slice should return nil") - } - }) - - t.Run("when data exist", func(t *testing.T) { - testData := testDataArrStringFromFlag - expected := testDataEnvMap - actual := config.ParseEnvFlagToMap(testData) - - if !reflect.DeepEqual(expected, actual) { - t.Error("Not meet expectation", expected, "-", actual) - } - }) -} - -// ParseEnvFlagToEnv should parse slice of string "var=val" to []ENV -func TestParseEnvFlagToEnv(t *testing.T) { - - t.Run("when set empty data", func(t *testing.T) { - // nil data test - if config.ParseEnvFlagToEnv([]string{}) != nil { - t.Error("Not meet expectation. empty slice should return nil") - } - }) - - t.Run("when data exist", func(t *testing.T) { - testData := testDataArrStringFromFlag - // invalid format should be ignored without error - actual := config.ParseEnvFlagToEnv(testData) - expected := testDataEnvs - // ParseEnvFlagToEnv sort the result. so expected should be sorted - config.SortEnv(expected) - if !reflect.DeepEqual(expected, actual) { - t.Error("Not meet expectation", expected, "-", actual) - } - }) -} - -// test MapToEnv func -func TestMapToEnv(t *testing.T) { - testData := testDataEnvMap - expected := testDataEnvs - // sort. MapToEnv sort the result. so expected should be sorted - config.SortEnv(expected) - actual := config.MapToEnv(testData) - if !reflect.DeepEqual(expected, actual) { - t.Error("Not meet expectation", expected, "-", actual) - } -} - func TestDefaultProfile(t *testing.T) { cfg := testDataConfig() diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..61ba78b --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,114 @@ +package config + +import ( + "fmt" + "sort" + "strings" +) + +// Env represent environment variable name and value +// go yaml doesn't support capitalized key. so follow k8s env format +type Env struct { + Name string `mapstructure:"name" yaml:"name"` + Value string `mapstructure:"value" yaml:"value"` +} + +// Override String() to make it KEY=VAL format +func (e Env) String() string { + return fmt.Sprint(e.Name, "=", e.Value) +} + +// Envs is slice of Env +type Envs []*Env + +func (e *Envs) AddEnv(name, value string) { + env := &Env{Name: name, Value: value} + *e = append(*e, env) +} + +// Strings returns KEY=VAL array of Env +func (e Envs) Strings() []string { + s := []string{} + for _, i := range e { + s = append(s, i.String()) + } + return s +} + +// Override strings() of Envs go generate comma separated string. this will be used for displaying env vars in list and start command. +func (e Envs) String() string { + s := []string{} + for _, i := range e { + s = append(s, i.String()) + } + r := strings.Join(s, ",") + return r +} + +// ParseEnvFlagToMap parse string format "env=val" to map "env: val". it can be used fo dup check from slice of Env +func ParseEnvFlagToMap(envs []string) map[string]string { + + if len(envs) == 0 { + return nil + } + + r := map[string]string{} + + for _, s := range envs { + ev := strings.Split(s, "=") + if len(ev) != 2 { + // TODO: handle unexpected format + continue + } else { + r[ev[0]] = ev[1] + } + } + return r +} + +// ParseEnvFlagToEnv parse slice of string "var=val" to []ENV +func ParseEnvFlagToEnv(args []string) Envs { + if len(args) == 0 { + return nil + } + + r := []*Env{} + + for _, s := range args { + ev := strings.Split(s, "=") + if len(ev) != 2 { + // TODO: handle unexpected format + //fmt.Println("WARN: wrong format of env item. it must be var=val.", ev, "will be ignored") + continue + } else { + r = append(r, &Env{ + Name: ev[0], + Value: ev[1], + }) + } + } + SortEnv(r) + return r +} + +// MapToEnv parse string map to slice of Env +func MapToEnv(m map[string]string) Envs { + r := []*Env{} + for k, v := range m { + r = append(r, &Env{ + Name: k, + Value: v, + }) + } + // sort it by env name + SortEnv(r) + return r +} + +// SortEnv sort []Env by name asc +func SortEnv(e []*Env) { + // sort it by env name + sort.Slice(e, func(i, j int) bool { + return e[i].Name < e[j].Name + }) +} diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 0000000..0d6752b --- /dev/null +++ b/internal/config/env_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + testDataEnvs = Envs{ + &Env{Name: "VAR_C", Value: "VAL_C"}, + &Env{Name: "VAR_A", Value: "VAL_A"}, + &Env{Name: "VAR_D", Value: "VAL_D"}, + &Env{Name: "VAR_B", Value: "VAL_B"}, + } + + testDataEnvMap = map[string]string{ + "VAR_A": "VAL_A", + "VAR_B": "VAL_B", + "VAR_C": "VAL_C", + "VAR_D": "VAL_D", + } + + testDataArrStringFromFlag = []string{ + "VAR_A=VAL_A", + "something_not_valid", // should be ignored + "VAR_B=VAL_B", + "VAR_C=VAL_C", + "VAR_D=VAL_D", + "not:valid", // should be ignored + " ", // should be ignored + "how=about=this", // should be ignored + } +) + +// test String() method and SortEnv +func TestEnv(t *testing.T) { + envs := testDataEnvs + // sort + SortEnv(envs) + + // data must be sorted in key + // Env should return string in VAR=VAL format + // Envs should return comma separated string + expected := "VAR_A=VAL_A,VAR_B=VAL_B,VAR_C=VAL_C,VAR_D=VAL_D" + actual := envs.String() + if expected != actual { + t.Error("Not meet expectation", expected, "-", actual) + } +} + +// test ParseEnvFlagToMap func +// ParseEnvFlagToMap should parse string format "env=val" to map "env: val" +func TestParseEnvFlagToMap(t *testing.T) { + + t.Run("when set empty data", func(t *testing.T) { + // nil data test + if ParseEnvFlagToMap([]string{}) != nil { + t.Error("Not meet expectation. empty slice should return nil") + } + }) + + t.Run("when data exist", func(t *testing.T) { + testData := testDataArrStringFromFlag + expected := testDataEnvMap + actual := ParseEnvFlagToMap(testData) + + if !reflect.DeepEqual(expected, actual) { + t.Error("Not meet expectation", expected, "-", actual) + } + }) +} + +// ParseEnvFlagToEnv should parse slice of string "var=val" to []ENV +func TestParseEnvFlagToEnv(t *testing.T) { + + t.Run("when set empty data", func(t *testing.T) { + // nil data test + if ParseEnvFlagToEnv([]string{}) != nil { + t.Error("Not meet expectation. empty slice should return nil") + } + }) + + t.Run("when data exist", func(t *testing.T) { + testData := testDataArrStringFromFlag + // invalid format should be ignored without error + actual := ParseEnvFlagToEnv(testData) + expected := testDataEnvs + // ParseEnvFlagToEnv sort the result. so expected should be sorted + SortEnv(expected) + if !reflect.DeepEqual(expected, actual) { + t.Error("Not meet expectation", expected, "-", actual) + } + }) +} + +// test MapToEnv func +func TestMapToEnv(t *testing.T) { + testData := testDataEnvMap + expected := testDataEnvs + // sort. MapToEnv sort the result. so expected should be sorted + SortEnv(expected) + actual := MapToEnv(testData) + if !reflect.DeepEqual(expected, actual) { + t.Error("Not meet expectation", expected, "-", actual) + } +} + +// TestEnvsStrings tests Strings func in Envs +func TestEnvsStrings(t *testing.T) { + + expected := []string{"VAR_C=VAL_C", "VAR_A=VAL_A", "VAR_D=VAL_D", "VAR_B=VAL_B"} + assert.ElementsMatch(t, expected, testDataEnvs.Strings()) +} + +// TestEnvsAddEnv tests AddEnv func in Envs +func TestEnvsAddEnv(t *testing.T) { + + expected := []string{"VAR_1=VAL_1", "VAR_2=VAL_2", "VAR_3=VAL_3"} + + actual := Envs{} + actual.AddEnv("VAR_1", "VAL_1") + actual.AddEnv("VAR_2", "VAL_2") + actual.AddEnv("VAR_3", "VAL_3") + + assert.ElementsMatch(t, expected, actual.Strings()) +} diff --git a/internal/config/profile.go b/internal/config/profile.go index b7eda09..b9a956b 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -33,16 +33,6 @@ type NamedProfile struct { IsDefault bool } -// Envs is slice of Env -type Envs []Env - -// Env represent environment variable name and value -// go yaml doesn't support capitalized key. so follow k8s env format -type Env struct { - Name string `mapstructure:"name" yaml:"name"` - Value string `mapstructure:"value" yaml:"value"` -} - // ProfileNotExistingError is an error when expected profile is not existing type ProfileNotExistingError struct { profile string @@ -73,21 +63,6 @@ func NewProfileNameInputEmptyError() *ProfileNameInputEmptyError { return &ProfileNameInputEmptyError{} } -// Override String() to make it KEY=VAL format -func (e Env) String() string { - return fmt.Sprint(e.Name, "=", e.Value) -} - -// Override strings() of Envs go generate comma separated string. this will be used for displaying env vars in list and start command. -func (e Envs) String() string { - s := []string{} - for _, i := range e { - s = append(s, i.String()) - } - r := strings.Join(s, ",") - return r -} - // SetProfile sets profile into the Profiles // key is dot "." delimetered or plain string without no space. // if it is dot delimeterd, considering it as nested profile diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 8ad0963..36f378b 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -1,10 +1,13 @@ package shell import ( + "errors" "fmt" "io" "os" "os/exec" + "regexp" + "strings" "github.com/fatih/color" "github.com/sunggun-yu/envp/internal/config" @@ -13,7 +16,7 @@ import ( // TODO: refactoring, cleanup // TODO: considering of using context -// TODO: poc of using forkExec and handling sigs, norifying sigs via channel and so on. +// TODO: poc of using forkExec and handling sigs, notifying sigs via channel and so on. const envpEnvVarKey = "ENVP_PROFILE" @@ -42,6 +45,11 @@ func (s *ShellCommand) Execute(cmd []string, env config.Envs, profile string) er func (s *ShellCommand) StartShell(env config.Envs, profile string) error { sh := os.Getenv("SHELL") + // use /bin/sh if SHELL is not set + if sh == "" { + sh = "/bin/sh" + } + // TODO: do some template // print start of session message s.Stdout.Write([]byte(fmt.Sprintln(color.GreenString("Starting ENVP session..."), color.RedString(profile)))) @@ -49,6 +57,9 @@ func (s *ShellCommand) StartShell(env config.Envs, profile string) error { // execute the command err := s.execCommand(sh, []string{sh, "-c", sh}, env, profile) + if err != nil { + s.Stderr.Write([]byte(fmt.Sprintln(color.MagentaString(err.Error())))) + } // TODO: do some template // print end of session message @@ -57,8 +68,8 @@ func (s *ShellCommand) StartShell(env config.Envs, profile string) error { return err } -// execCommand executes the os/exec Command with environment variales injection -func (s *ShellCommand) execCommand(argv0 string, argv []string, env config.Envs, profile string) error { +// execCommand executes the os/exec Command with environment variables injection +func (s *ShellCommand) execCommand(argv0 string, argv []string, envs config.Envs, profile string) error { // first arg should be the command to execute // check if command can be found in the PATH binary, err := exec.LookPath(argv0) @@ -73,8 +84,19 @@ func (s *ShellCommand) execCommand(argv0 string, argv []string, env config.Envs, cmd.Stdout = s.Stdout cmd.Stdin = s.Stdin cmd.Stderr = s.Stderr + + // init cmd.Env with os.Environ() + cmd.Env = os.Environ() + // set ENVP_PROFILE + cmd.Env = appendEnvpProfile(cmd.Env, profile) + + err = parseEnvs(envs) + if err != nil { + return err + } + // merge into os environment variables and set into the cmd - cmd.Env = append(os.Environ(), appendEnvpProfile(parseEnvs(env), profile)...) + cmd.Env = append(cmd.Env, envs.Strings()...) // run command if err := cmd.Run(); err != nil { @@ -85,15 +107,20 @@ func (s *ShellCommand) execCommand(argv0 string, argv []string, env config.Envs, } // parseEnvs parse config.Envs to "VAR=VAL" format string slice -func parseEnvs(env config.Envs) []string { - ev := []string{} - for _, e := range env { +func parseEnvs(envs config.Envs) (errs error) { + for _, e := range envs { // it's ok to ignore error. it returns original value if it doesn't contain the home path - v, _ := util.ExpandHomeDir(e.Value) - // Env.String() would not work for this case since we want to cover expanding the home dir path - ev = append(ev, fmt.Sprintf("%s=%s", e.Name, v)) + e.Value, _ = util.ExpandHomeDir(e.Value) + // parse command substitution value like $(some-command). treat error to let user to know there is error with it + v, err := processCommandSubstitutionValue(e.Value, envs) + if err != nil { + // join errors + errs = errors.Join(errs, fmt.Errorf("[envp] error processing value of %s: %s", e.Name, err)) + } else { + e.Value = v + } } - return ev + return errs } // appendEnvpProfile set ENVP_PROFILE env var to leverage profile info in the shell prompt, such as starship. @@ -101,3 +128,29 @@ func appendEnvpProfile(envs []string, profile string) []string { envs = append(envs, fmt.Sprintf("%s=%s", envpEnvVarKey, profile)) return envs } + +// processCommandSubstitutionValue checks whether the env value is in the format of shell substitution $() and runs the shell to replace the env value with the result of its execution. +func processCommandSubstitutionValue(val string, envs config.Envs) (string, error) { + // check if val is pattern of command substitution using regex + // support only $() substitution. not support `` substitution + re := regexp.MustCompile(`^\$\((.*?)\)`) // use MustCompile. no expect it's failing + + matches := re.FindStringSubmatch(val) + if len(matches) < 2 { + // no valid script found. just return original value + return val, nil + } + + script := strings.TrimSpace(matches[1]) + cmd := exec.Command("sh", "-c", script) + // append envs to cmd that runs command substitution as well to support the case that reuse env var as ref with substitution + cmd.Env = append(cmd.Env, envs.Strings()...) + + // output, err := cmd.CombinedOutput() + output, err := cmd.Output() + if err != nil { + return val, fmt.Errorf("error executing script: %v", err) + } + + return strings.TrimRight(string(output), "\r\n"), nil +} diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index f8bab7a..7826e9f 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -21,7 +21,7 @@ var _ = Describe("Shell", func() { When("passing non-empty envs", func() { It("should not return err", func() { cmd := "echo" - err := sc.Execute([]string{cmd}, []config.Env{ + err := sc.Execute([]string{cmd}, []*config.Env{ {Name: "meow", Value: "woof"}, }, "my-profile") Expect(err).ToNot(HaveOccurred()) @@ -31,7 +31,7 @@ var _ = Describe("Shell", func() { When("pass wrong arg to command", func() { It("should return err", func() { cmd := []string{"cat", "/not-existing-dir/not-existing-file-rand-meow"} - err := sc.Execute(cmd, []config.Env{ + err := sc.Execute(cmd, []*config.Env{ {Name: "meow", Value: "woof"}, }, "my-profile") Expect(err).To(HaveOccurred()) @@ -42,7 +42,7 @@ var _ = Describe("Shell", func() { When("run non-existing command", func() { It("should not return err", func() { cmd := "" - err := sc.Execute([]string{cmd}, []config.Env{ + err := sc.Execute([]string{cmd}, []*config.Env{ {Name: "meow", Value: "woof"}, }, "my-profile") Expect(err).To(HaveOccurred()) @@ -66,7 +66,7 @@ var _ = Describe("Shell", func() { When("pass not empty envs", func() { It("should not return err", func() { - err := sc.StartShell([]config.Env{ + err := sc.StartShell([]*config.Env{ {Name: "meow", Value: "woof"}, }, "my-profile") Expect(err).ToNot(HaveOccurred()) @@ -87,12 +87,12 @@ var _ = Describe("Shell", func() { sh := os.Getenv("SHELL") JustBeforeEach(func() { - // make SHELL empty to occur error + // make SHELL empty for test case os.Setenv("SHELL", "") }) - It("should return err", func() { + It("it should not return err since it uses /bin/sh as default shell even SHELL is empty", func() { err := sc.StartShell(nil, "my-profile") - Expect(err).To(HaveOccurred()) + Expect(err).NotTo(HaveOccurred()) }) JustAfterEach(func() { // revert SHELL to original @@ -104,27 +104,25 @@ var _ = Describe("Shell", func() { var _ = Describe("env functions", func() { - envs := config.Envs{ - config.Env{ - Name: "PATH", - Value: "~/.config", - }, - config.Env{ - Name: "HOME", - Value: "$HOME", - }, - } + envs := config.Envs{} + envs.AddEnv("PATH", "~/.config") + envs.AddEnv("HOME", "$HOME") + errs := parseEnvs(envs) h, _ := os.UserHomeDir() - pe := appendEnvpProfile(parseEnvs(envs), "my-profile") + pe := appendEnvpProfile(envs.Strings(), "my-profile") + + It("should not occur error", func() { + Expect(errs).ToNot(HaveOccurred()) + }) When("has ~ in the value", func() { - It("should extraced to abs home dir", func() { + It("should be extracted to abs home dir", func() { Expect(pe).To(ContainElement(fmt.Sprintf("PATH=%s/.config", h))) }) }) When("has $HOME in the value", func() { - It("should extraced to abs home dir", func() { + It("should be extracted to abs home dir", func() { Expect(pe).To(ContainElement(fmt.Sprintf("HOME=%s", h))) }) }) @@ -135,3 +133,48 @@ var _ = Describe("env functions", func() { }) }) }) + +var _ = Describe("env shell command substitution", func() { + + envs := config.Envs{} + envs.AddEnv("TEST_1", "VALUE_1") + envs.AddEnv("TEST_SUBST_1", "$(echo hello)") + envs.AddEnv("TEST_SUBST_2", "$(echo $TEST_1)") + envs.AddEnv("TEST_SUBST_3", "$(this-is-error)") + errs := parseEnvs(envs) + + When("has $() in the value", func() { + It("should perform shell command substitution", func() { + Expect(envs.Strings()).To(ContainElement("TEST_SUBST_1=hello")) + }) + }) + + When("referring another (earlier) env variable with command substitution", func() { + It("should get the value of other env var value and substitute with it", func() { + Expect(envs.Strings()).To(ContainElement("TEST_SUBST_2=VALUE_1")) + }) + }) + + When("command in substitution is wrong", func() { + It("should occur error", func() { + Expect(errs).To(HaveOccurred()) + }) + + It("should not perform substitution. keep original value", func() { + Expect(envs.Strings()).To(ContainElement("TEST_SUBST_3=$(this-is-error)")) + }) + + var stdout, stderr bytes.Buffer + sc := NewShellCommand() + sc.Stdout = &stdout + sc.Stderr = &stderr + + It("StartShell should show parsing error message in stderr", func() { + + err := sc.StartShell(envs, "my-profile") + Expect(err).To(HaveOccurred()) + Expect(stderr.String()).NotTo(BeEmpty()) + Expect(stderr.String()).To(ContainSubstring("error processing value of TEST_SUBST_3")) + }) + }) +})