Repository: Shopify/themekit
Pull request #589 (2018-10-15 15:55:49)

Add automatic environment variable loading and Variables in yml config.
### Features
This PR encapsulates 2 main features.
- Automatically load environment variables from a file during runtime
- Allow environment variable interpolation in the config 

This will allow developers to much more easily commit their code to public repos without revealing their passwords or their clients.

### Environment variable loading 
The normal user configuration paths are used so depending on OS the path used would be:
Windows:  `%APPDATA%\Shopify\Themekit\variables` 
Linux/BSDs: `${XDG_CONFIG_HOME}/Shopify/Themekit/variables` 
MacOSX: `${HOME}/Library/Application Support/Shopify/Themekit/variables`
    
Themekit will also look for a variables file within the project directory. There has also been a flag added `--vars` will provide a path to a variables file.

The format of these files would look like a normal .env file:
```
DEV_PASSWD=0bwef09hn23048sdkl2345n2k3
```

### Config file interpolation.
The config looks would like this:
```
development:
  password: ${DEV_PASSWD}
  theme_id: ${DEV_THEMEID}
  store: ${DEV_SHOP}
```

and the commands can be run as normal. This will not add any breaking functionality and should not break any existing workflows

### Warn Checklist
- [x] This changes the interface and requires a Major/Minor version change.
- [x] I have :tophat:'d these changes by using the commands I changed by hand.
- [x] I have added a dependancy to the project.



Patch:
diff --git a/changelog.txt b/changelog.txt
index e68dd53..2346b94 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -4,6 +4,9 @@ v1.0.0
 - Fixed a problem with uploading compiled assets
 - Decoupled config file from command execution so any command can run without
 	a config file.
+- Added environment variable interpolation into config files.
+- Added a global environment variable file so that variables can be easily
+managed
 
 v0.8.1 (Sept 18, 2018)
 ======================
diff --git a/cmd/theme.go b/cmd/theme.go
index 85e30a7..a15f6e5 100644
--- a/cmd/theme.go
+++ b/cmd/theme.go
@@ -75,6 +75,7 @@ func init() {
 	defaultConfigPath := filepath.Join(pwd, "config.yml")
 
 	ThemeCmd.PersistentFlags().StringVarP(&flags.ConfigPath, "config", "c", defaultConfigPath, "path to config.yml")
+	ThemeCmd.PersistentFlags().StringVar(&flags.VariableFilePath, "vars", "", "path to an file that defines environment variables")
 	ThemeCmd.PersistentFlags().StringArrayVarP(&flags.Environments, "env", "e", []string{env.Default.Name}, "environment to run the command")
 	ThemeCmd.PersistentFlags().StringVarP(&flags.Directory, "dir", "d", "", "directory that command will take effect. (default current directory)")
 	ThemeCmd.PersistentFlags().StringVarP(&flags.Password, "password", "p", "", "theme password. This will override what is in your config.yml")
diff --git a/go.mod b/go.mod
index 480c73b..7a21ca7 100644
--- a/go.mod
+++ b/go.mod
@@ -11,10 +11,12 @@ require (
 	github.com/imdario/mergo v0.3.6
 	github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
+	github.com/joho/godotenv v1.3.0
 	github.com/mattn/go-colorable v0.0.0-20180310133214-efa589957cd0
 	github.com/mattn/go-isatty v0.0.4 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/ryanuber/go-glob v0.0.0-20160226084822-572520ed46db
+	github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0
 	github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c
 	github.com/spf13/cobra v0.0.0-20180722215644-7c4570c3ebeb
 	github.com/spf13/pflag v1.0.2 // indirect
diff --git a/go.sum b/go.sum
index e363784..2c4576e 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
+github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/mattn/go-colorable v0.0.0-20180310133214-efa589957cd0 h1:cDvUG90i1ssGJGqMNx2Ubbn+bx7VOzjdvQ45zpy0X4w=
 github.com/mattn/go-colorable v0.0.0-20180310133214-efa589957cd0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
@@ -30,6 +32,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/ryanuber/go-glob v0.0.0-20160226084822-572520ed46db h1:ge9atzKq16843f793fDVxKUhmTb4H5muzjJQ6PgsnHg=
 github.com/ryanuber/go-glob v0.0.0-20160226084822-572520ed46db/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 h1:Xuk8ma/ibJ1fOy4Ee11vHhUFHQNpHhrBneOCNHVXS5w=
+github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0/go.mod h1:7AwjWCpdPhkSmNAgUv5C7EJ4AbmjEB3r047r3DXWu3Y=
 github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c h1:fyKiXKO1/I/B6Y2U8T7WdQGWzwehOuGIrljPtt7YTTI=
 github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
 github.com/spf13/cobra v0.0.0-20180722215644-7c4570c3ebeb h1:9EsYJzSlhhaP+nYmMOcptMF2VEUH52jxPzt/TX14KWM=
diff --git a/src/cmdutil/util.go b/src/cmdutil/util.go
index 8d65f56..85f8238 100644
--- a/src/cmdutil/util.go
+++ b/src/cmdutil/util.go
@@ -27,6 +27,7 @@ var ErrReload = errors.New("reloading config")
 // command line. Some of the values are used across different commands
 type Flags struct {
 	ConfigPath            string
+	VariableFilePath      string
 	Environments          []string
 	Directory             string
 	Password              string
@@ -179,6 +180,10 @@ func generateContexts(newClient clientFact, progress *mpb.Progress, flags Flags,
 	ctxs := []*Ctx{}
 	flagEnv := getFlagEnv(flags)
 
+	if err := env.SourceVariables(flags.VariableFilePath); err != nil {
+		return ctxs, err
+	}
+
 	config, err := env.Load(flags.ConfigPath)
 	if err != nil && os.IsNotExist(err) {
 		colors.ColorStdOut.Printf(
diff --git a/src/env/conf.go b/src/env/conf.go
index 6345249..7ba68d3 100644
--- a/src/env/conf.go
+++ b/src/env/conf.go
@@ -10,10 +10,19 @@ import (
 
 	"encoding/json"
 	"github.com/caarlos0/env"
+	"github.com/joho/godotenv"
+	"github.com/shibukawa/configdir"
 	"gopkg.in/yaml.v1"
 )
 
+const (
+	vendor   = "Shopify"
+	appname  = "Themekit"
+	filename = "variables"
+)
+
 var (
+	cdir          = configdir.New(vendor, appname)
 	supportedExts = []string{"yml", "yaml", "json"}
 	// ErrEnvDoesNotExist is returned when an environment that does not exist in the config is requested
 	ErrEnvDoesNotExist = errors.New("environment does not exist in this environments list")
@@ -32,6 +41,22 @@ type Conf struct {
 	path  string
 }
 
+func init() {
+	cdir.LocalPath, _ = os.Getwd()
+}
+
+// SourceVariables will find the environment files for configuration and import their
+// environment variables into the current running process
+func SourceVariables(flagpath string) error {
+	if flagpath != "" {
+		return godotenv.Load(flagpath)
+	}
+	if fldr := cdir.QueryFolderContainsFile(filename); fldr != nil {
+		return godotenv.Load(filepath.Join(fldr.Path, filename))
+	}
+	return nil
+}
+
 // New will build a new blank config
 func New(configPath string) Conf {
 	conf := Conf{
@@ -53,16 +78,20 @@ func Load(configPath string) (Conf, error) {
 	}
 
 	contents, err := ioutil.ReadFile(path)
-	if err == nil {
-		switch ext {
-		case "yml", "yaml":
-			if err = yaml.Unmarshal(contents, &conf.Envs); err != nil {
-				return conf, fmt.Errorf("Invalid yaml found while loading the config file: %v", err)
-			}
-		case "json":
-			if err = json.Unmarshal(contents, &conf.Envs); err != nil {
-				return conf, fmt.Errorf("Invalid json found while loading the config file: %v", err)
-			}
+	if err != nil {
+		return conf, err
+	}
+
+	contents = []byte(os.ExpandEnv(string(contents)))
+
+	switch ext {
+	case "yml", "yaml":
+		if err = yaml.Unmarshal(contents, &conf.Envs); err != nil {
+			return conf, fmt.Errorf("Invalid yaml found while loading the config file: %v", err)
+		}
+	case "json":
+		if err = json.Unmarshal(contents, &conf.Envs); err != nil {
+			return conf, fmt.Errorf("Invalid json found while loading the config file: %v", err)
 		}
 	}
 


Test patch:
diff --git a/src/env/_testdata/projectdir/valid_config.yml b/src/env/_testdata/projectdir/valid_config.yml
index a6f1f79..fcdc1c4 100644
--- a/src/env/_testdata/projectdir/valid_config.yml
+++ b/src/env/_testdata/projectdir/valid_config.yml
@@ -1,6 +1,6 @@
 development:
-  password: abracadabra
-  store: store.myshopify.com
+  password: ${STOREPASS}
+  store: $STOREDOMAIN
   ignore_files:
   - charmander
   - bulbasaur
diff --git a/src/env/conf_test.go b/src/env/conf_test.go
index 742a9a0..e0d158e 100644
--- a/src/env/conf_test.go
+++ b/src/env/conf_test.go
@@ -37,6 +37,15 @@ func TestLoad(t *testing.T) {
 			assert.Contains(t, err.Error(), testcase.err)
 		}
 	}
+
+	os.Setenv("STOREPASS", "abracadabra")
+	os.Setenv("STOREDOMAIN", "magic.myshopify.com")
+	defer os.Unsetenv("STOREPASS")
+	defer os.Unsetenv("STOREDOMAIN")
+	conf, err := Load("_testdata/projectdir/valid_config.yml")
+	assert.Nil(t, err)
+	assert.Equal(t, "abracadabra", conf.Envs["development"].Password)
+	assert.Equal(t, "magic.myshopify.com", conf.Envs["development"].Domain)
 }
 
 func TestSearchConfigPath(t *testing.T) {
@@ -87,6 +96,11 @@ func TestConf_Set(t *testing.T) {
 }
 
 func TestConf_Get(t *testing.T) {
+	os.Setenv("STOREPASS", "abracadabra")
+	os.Setenv("STOREDOMAIN", "magic.myshopify.com")
+	defer os.Unsetenv("STOREPASS")
+	defer os.Unsetenv("STOREDOMAIN")
+
 	testcases := []struct {
 		path, toGet, themeid string
 		err                  error