Skip to content

Commit e4bd4a9

Browse files
authored
Merge pull request #324 from roots/cli-config-refactor
CLI config refactor and improvements
2 parents 561fdee + c2611b7 commit e4bd4a9

File tree

17 files changed

+477
-140
lines changed

17 files changed

+477
-140
lines changed

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,54 @@ Supported commands so far:
149149
| `valet` | Commands for Laravel Valet |
150150
| `vault` | Commands for Ansible Vault |
151151
152+
## Configuration
153+
There are three ways to set configuration settings for trellis-cli and they are
154+
loaded in this order of precedence:
155+
156+
1. global config
157+
2. project config
158+
3. env variables
159+
160+
The global CLI config (defaults to `$HOME/.config/trellis/cli.yml`)
161+
and will be loaded first (if it exists).
162+
163+
Next, if a project is detected, the project CLI config will be loaded if it
164+
exists at `.trellis/cli.yml`.
165+
166+
Finally, env variables prefixed with `TRELLIS_` will be used as
167+
overrides if they match a supported configuration setting. The prefix will be
168+
stripped and the rest is lowercased to determine the setting key.
169+
170+
Note: only string, numeric, and boolean values are supported when using environment
171+
variables.
172+
173+
Current supported settings:
174+
175+
| Setting | Description | Type | Default |
176+
| --- | --- | -- | -- |
177+
| `ask_vault_pass` | Set Ansible to always ask for the vault pass | boolean | false |
178+
| `check_for_updates` | Whether to check for new versions of trellis-cli | boolean | true |
179+
| `load_plugins` | Load external CLI plugins | boolean | true |
180+
| `open` | List of name -> URL shortcuts | map[string]string | none |
181+
| `virtualenv_integration` | Enable automated virtualenv integration | boolean | true |
182+
183+
Example config:
184+
185+
```yaml
186+
ask_vault_pass: false
187+
check_for_updates: true
188+
load_plugins: true
189+
open:
190+
site: "https://mysite.com"
191+
admin: "https://mysite.com/wp/wp-admin"
192+
virtualenv_integration: true
193+
```
194+
195+
Example env var usage:
196+
```bash
197+
TRELLIS_ASK_VAULT_PASS=true trellis provision production
198+
```
199+
152200
## Development
153201
154202
trellis-cli requires Go >= 1.18 (`brew install go` on macOS)

app_paths/app_paths.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package app_paths
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
)
8+
9+
const (
10+
appData = "AppData"
11+
trellisConfigDir = "TRELLIS_CONFIG_DIR"
12+
localAppData = "LocalAppData"
13+
xdgCacheHome = "XDG_CACHE_HOME"
14+
xdgConfigHome = "XDG_CONFIG_HOME"
15+
xdgDataHome = "XDG_DATA_HOME"
16+
)
17+
18+
// Config path precedence: TRELLIS_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME.
19+
func ConfigDir() string {
20+
var path string
21+
22+
if a := os.Getenv(trellisConfigDir); a != "" {
23+
path = a
24+
} else if b := os.Getenv(xdgConfigHome); b != "" {
25+
path = filepath.Join(b, "trellis")
26+
} else if c := os.Getenv(appData); runtime.GOOS == "windows" && c != "" {
27+
path = filepath.Join(c, "Trellis CLI")
28+
} else {
29+
d, _ := os.UserHomeDir()
30+
path = filepath.Join(d, ".config", "trellis")
31+
}
32+
33+
return path
34+
}
35+
36+
func ConfigPath(path string) string {
37+
return filepath.Join(ConfigDir(), path)
38+
}
39+
40+
// Cache path precedence: XDG_CACHE_HOME, LocalAppData (windows only), HOME.
41+
func CacheDir() string {
42+
var path string
43+
if a := os.Getenv(xdgCacheHome); a != "" {
44+
path = filepath.Join(a, "trellis")
45+
} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
46+
path = filepath.Join(b, "Trellis CLI")
47+
} else {
48+
c, _ := os.UserHomeDir()
49+
path = filepath.Join(c, ".local", "state", "trellis")
50+
}
51+
return path
52+
}

cli_config/cli_config.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package cli_config
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"reflect"
8+
"strconv"
9+
"strings"
10+
11+
"gopkg.in/yaml.v2"
12+
)
13+
14+
type Config struct {
15+
AskVaultPass bool `yaml:"ask_vault_pass"`
16+
CheckForUpdates bool `yaml:"check_for_updates"`
17+
LoadPlugins bool `yaml:"load_plugins"`
18+
Open map[string]string `yaml:"open"`
19+
VirtualenvIntegration bool `yaml:"virtualenv_integration"`
20+
}
21+
22+
var (
23+
ErrUnsupportedType = errors.New("Invalid env var config setting: value is an unsupported type.")
24+
ErrCouldNotParse = errors.New("Invalid env var config setting: failed to parse value")
25+
)
26+
27+
func NewConfig(defaultConfig Config) Config {
28+
return defaultConfig
29+
}
30+
31+
func (c *Config) LoadFile(path string) error {
32+
configYaml, err := os.ReadFile(path)
33+
34+
if err != nil && !os.IsNotExist(err) {
35+
return err
36+
}
37+
38+
if err := yaml.Unmarshal(configYaml, &c); err != nil {
39+
return err
40+
}
41+
42+
return nil
43+
}
44+
45+
func (c *Config) LoadEnv(prefix string) error {
46+
structType := reflect.ValueOf(c).Elem()
47+
fields := reflect.VisibleFields(structType.Type())
48+
49+
for _, env := range os.Environ() {
50+
parts := strings.Split(env, "=")
51+
originalKey := parts[0]
52+
value := parts[1]
53+
54+
key := strings.TrimPrefix(originalKey, prefix)
55+
56+
if originalKey == key {
57+
// key is unchanged and didn't start with prefix
58+
continue
59+
}
60+
61+
for _, field := range fields {
62+
if strings.ToLower(key) == field.Tag.Get("yaml") {
63+
structValue := structType.FieldByName(field.Name)
64+
65+
if !structValue.CanSet() {
66+
continue
67+
}
68+
69+
switch field.Type.Kind() {
70+
case reflect.Bool:
71+
val, err := strconv.ParseBool(value)
72+
73+
if err != nil {
74+
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a boolean", ErrCouldNotParse, env, value)
75+
}
76+
77+
structValue.SetBool(val)
78+
case reflect.Int:
79+
val, err := strconv.ParseInt(value, 10, 32)
80+
81+
if err != nil {
82+
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as an integer", ErrCouldNotParse, env, value)
83+
}
84+
85+
structValue.SetInt(val)
86+
case reflect.Float32:
87+
val, err := strconv.ParseFloat(value, 32)
88+
if err != nil {
89+
return fmt.Errorf("%w '%s'\n'%s' can't be parsed as a float", ErrCouldNotParse, env, value)
90+
}
91+
92+
structValue.SetFloat(val)
93+
default:
94+
return fmt.Errorf("%w\n%s setting of type %s is unsupported.", ErrUnsupportedType, env, field.Type.String())
95+
}
96+
}
97+
}
98+
}
99+
100+
return nil
101+
}

cli_config/cli_config_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package cli_config
2+
3+
import (
4+
_ "fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestLoadFile(t *testing.T) {
12+
conf := Config{
13+
AskVaultPass: false,
14+
LoadPlugins: true,
15+
}
16+
17+
dir := t.TempDir()
18+
path := filepath.Join(dir, "cli.yml")
19+
content := `
20+
ask_vault_pass: true
21+
open:
22+
roots: https://roots.io
23+
`
24+
25+
if err := os.WriteFile(path, []byte(content), os.ModePerm); err != nil {
26+
t.Fatal(err)
27+
}
28+
29+
conf.LoadFile(path)
30+
31+
if conf.LoadPlugins != true {
32+
t.Errorf("expected LoadPlugins to be true (default value)")
33+
}
34+
35+
if conf.AskVaultPass != true {
36+
t.Errorf("expected AskVaultPass to be true")
37+
}
38+
39+
open := conf.Open["roots"]
40+
expected := "https://roots.io"
41+
42+
if open != expected {
43+
t.Errorf("expected open to be %s, got %s", expected, open)
44+
}
45+
}
46+
47+
func TestLoadEnv(t *testing.T) {
48+
t.Setenv("TRELLIS_ASK_VAULT_PASS", "true")
49+
t.Setenv("TRELLIS_NOPE", "foo")
50+
t.Setenv("ASK_VAULT_PASS", "false")
51+
52+
conf := Config{
53+
AskVaultPass: false,
54+
}
55+
56+
conf.LoadEnv("TRELLIS_")
57+
58+
if conf.AskVaultPass != true {
59+
t.Errorf("expected AskVaultPass to be true")
60+
}
61+
}
62+
63+
func TestLoadBoolParseError(t *testing.T) {
64+
t.Setenv("TRELLIS_ASK_VAULT_PASS", "foo")
65+
66+
conf := Config{}
67+
68+
err := conf.LoadEnv("TRELLIS_")
69+
70+
if err == nil {
71+
t.Errorf("expected LoadEnv to return an error")
72+
}
73+
74+
msg := err.Error()
75+
76+
expected := `
77+
Invalid env var config setting: failed to parse value 'TRELLIS_ASK_VAULT_PASS=foo'
78+
'foo' can't be parsed as a boolean
79+
`
80+
81+
if msg != strings.TrimSpace(expected) {
82+
t.Errorf("expected error %s got %s", expected, msg)
83+
}
84+
}
85+
86+
func TestLoadEnvUnsupportedType(t *testing.T) {
87+
t.Setenv("TRELLIS_OPEN", "foo")
88+
89+
conf := Config{}
90+
91+
err := conf.LoadEnv("TRELLIS_")
92+
93+
if err == nil {
94+
t.Errorf("expected LoadEnv to return an error")
95+
}
96+
97+
msg := err.Error()
98+
99+
expected := `
100+
Invalid env var config setting: value is an unsupported type.
101+
TRELLIS_OPEN=foo setting of type map[string]string is unsupported.
102+
`
103+
104+
if msg != strings.TrimSpace(expected) {
105+
t.Errorf("expected error %s got %s", expected, msg)
106+
}
107+
}

cmd/init.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,5 @@ func virtualenvError(ui cli.Ui) {
210210
ui.Error(" 1. Ensure Python 3 is installed and the `python3` command works. trellis-cli will use python3's built-in venv feature.")
211211
ui.Error(" Ubuntu/Debian users (including Windows WSL): venv is not built-in, to install it run `sudo apt-get install python3-pip python3-venv`")
212212
ui.Error("")
213-
ui.Error(" 2. Disable trellis-cli's virtual env feature, and manage dependencies manually, by setting this env variable:")
214-
ui.Error(fmt.Sprintf(" export %s=false", trellis.TrellisVenvEnvName))
213+
ui.Error(" 2. Disable trellis-cli's virtualenv feature, and manage dependencies manually, by changing the 'virtualenv_integration' configuration setting to 'false'.")
215214
}

config/config.go

Lines changed: 0 additions & 7 deletions
This file was deleted.

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ require (
1313
github.com/mholt/archiver v3.1.1+incompatible
1414
github.com/mitchellh/cli v1.1.4
1515
github.com/mitchellh/go-homedir v1.1.0
16-
github.com/muesli/go-app-paths v0.2.2
1716
github.com/posener/complete v1.2.3
1817
github.com/theckman/yacspin v0.13.12
1918
github.com/weppos/publicsuffix-go v0.20.0

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,6 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk
198198
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
199199
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
200200
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
201-
github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI=
202-
github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho=
203201
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
204202
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
205203
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=

0 commit comments

Comments
 (0)