diff --git a/cmd/hcloud/main.go b/cmd/hcloud/main.go index 96b3f3eb..1fa10f8f 100644 --- a/cmd/hcloud/main.go +++ b/cmd/hcloud/main.go @@ -41,7 +41,7 @@ func main() { cfg := config.NewConfig() if err := config.ReadConfig(cfg, nil); err != nil { - log.Fatalf("unable to read config file: %s\n", err) + log.Fatalf("unable to read config file \"%s\": %s\n", cfg.Path(), err) } s, err := state.New(cfg) diff --git a/go.mod b/go.mod index ff356c55..b0ec4180 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/hetznercloud/cli go 1.21 require ( + github.com/BurntSushi/toml v1.3.2 github.com/boumenot/gocover-cobertura v1.2.0 github.com/cheggaaa/pb/v3 v3.1.5 github.com/dustin/go-humanize v1.0.1 @@ -12,9 +13,10 @@ require ( github.com/golang/mock v1.6.0 github.com/guptarohit/asciigraph v0.7.1 github.com/hetznercloud/hcloud-go/v2 v2.8.0 - github.com/pelletier/go-toml/v2 v2.2.2 + github.com/jedib0t/go-pretty/v6 v6.5.9 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.23.0 golang.org/x/term v0.20.0 @@ -24,17 +26,31 @@ require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.20.0 // indirect @@ -42,5 +58,6 @@ require ( golang.org/x/tools v0.17.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0e5a625a..c35584d0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -10,14 +12,19 @@ github.com/cheggaaa/pb/v3 v3.1.5 h1:QuuUzeM2WsAqG2gMqtzaWithDJv0i+i6UlnwSCI4QLk= github.com/cheggaaa/pb/v3 v3.1.5/go.mod h1:CrxkeghYTXi1lQBEI7jSn+3svI3cuc19haAj6jM60XI= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= @@ -32,18 +39,22 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/guptarohit/asciigraph v0.7.1 h1:K+JWbRc04XEfv8BSZgNuvhCmpbvX4+9NYd/UxXVnAuk= github.com/guptarohit/asciigraph v0.7.1/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hetznercloud/hcloud-go/v2 v2.8.0 h1:vfbfL/JfV8dIZUX7ANHWEbKNqgFWsETqvt/EctvoFJ0= github.com/hetznercloud/hcloud-go/v2 v2.8.0/go.mod h1:jvpP3qAWMIZ3WQwQLYa97ia6t98iPCgsJNwRts+Jnrk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jedib0t/go-pretty/v6 v6.5.8 h1:8BCzJdSvUbaDuRba4YVh+SKMGcAAKdkcF3SVFbrHAtQ= -github.com/jedib0t/go-pretty/v6 v6.5.8/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU= +github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -51,10 +62,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= @@ -68,26 +82,47 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= @@ -136,6 +171,8 @@ google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/config/add.go b/internal/cmd/config/add.go index 5f5a9af1..265a47eb 100644 --- a/internal/cmd/config/add.go +++ b/internal/cmd/config/add.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "slices" "github.com/spf13/cobra" @@ -50,7 +51,6 @@ func runAdd(s state.State, cmd *cobra.Command, args []string) error { var ( added []any - err error ctx config.Context prefs config.Preferences ) @@ -59,24 +59,40 @@ func runAdd(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global flag to set a global option)") } prefs = ctx.Preferences() } key, values := args[0], args[1:] - if added, err = prefs.Add(key, values); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) } + val, _ := prefs.Get(key) + switch opt.T().(type) { + case []string: + before := util.AnyToStringSlice(val) + newVal := append(before, values...) + slices.Sort(newVal) + newVal = slices.Compact(newVal) + val = newVal + added = util.ToAnySlice(util.SliceDiff[[]string](newVal, before)) + default: + return fmt.Errorf("%s is not a list", key) + } + + prefs.Set(key, val) + if len(added) == 0 { _, _ = fmt.Fprintln(os.Stderr, "Warning: no new values were added") } else if len(added) < len(values) { _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were already present or duplicate") } - if ctx == nil { + if util.IsNil(ctx) { cmd.Printf("Added '%v' to '%s' globally\n", added, key) } else { cmd.Printf("Added '%v' to '%s' in context '%s'\n", added, key, ctx.Name()) diff --git a/internal/cmd/config/add_test.go b/internal/cmd/config/add_test.go index 7ee76fa0..233b91cc 100644 --- a/internal/cmd/config/add_test.go +++ b/internal/cmd/config/add_test.go @@ -7,13 +7,58 @@ import ( "github.com/stretchr/testify/assert" configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" ) func TestAdd(t *testing.T) { + + _, deleteArrayOption := config.NewTestOption[[]string]( + "array-option", + "array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteArrayOption() + + _, deleteNestedArrayOption := config.NewTestOption[[]string]( + "nested.array-option", + "nested array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteNestedArrayOption() + + testConfig := ` +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + array_option = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { name string args []string + config string expOut string expErr string preRun func() @@ -22,9 +67,39 @@ func TestAdd(t *testing.T) { testCases := []testCase{ { - name: "add to existing", - args: []string{"default-ssh-keys", "a", "b", "c"}, - expOut: `active_context = "test_context" + name: "add to existing", + args: []string{"array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Added '[a b c]' to 'array-option' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + array_option = ["1", "2", "3", "a", "b", "c"] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "add to nested", + args: []string{"nested.array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Added '[a b c]' to 'nested.array-option' in context 'test_context' +active_context = "test_context" [preferences] debug = true @@ -34,9 +109,11 @@ func TestAdd(t *testing.T) { name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3", "a", "b", "c"] + array_option = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3", "a", "b", "c"] [[contexts]] name = "other_context" @@ -46,22 +123,26 @@ func TestAdd(t *testing.T) { `, }, { - name: "global add to empty", - args: []string{"--global", "default-ssh-keys", "a", "b", "c"}, - expOut: `active_context = "test_context" + name: "global add to empty", + args: []string{"--global", "array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Added '[a b c]' to 'array-option' globally +active_context = "test_context" [preferences] + array_option = ["a", "b", "c"] debug = true - default_ssh_keys = ["a", "b", "c"] poll_interval = "1.234s" [[contexts]] name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] + array_option = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] [[contexts]] name = "other_context" @@ -71,22 +152,27 @@ func TestAdd(t *testing.T) { `, }, { - name: "global add to empty duplicate", - args: []string{"--global", "default-ssh-keys", "c", "b", "c", "a", "a"}, - expOut: `active_context = "test_context" + name: "global add to empty duplicate", + args: []string{"--global", "array-option", "c", "b", "c", "a", "a"}, + config: testConfig, + expErr: "Warning: some values were already present or duplicate\n", + expOut: `Added '[a b c]' to 'array-option' globally +active_context = "test_context" [preferences] + array_option = ["a", "b", "c"] debug = true - default_ssh_keys = ["a", "b", "c"] poll_interval = "1.234s" [[contexts]] name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] + array_option = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] [[contexts]] name = "other_context" @@ -102,9 +188,11 @@ func TestAdd(t *testing.T) { postRun: func() { _ = os.Unsetenv("HCLOUD_CONTEXT") }, - name: "add to other context", - args: []string{"default-ssh-keys", "I", "II", "III"}, - expOut: `active_context = "test_context" + name: "add to other context", + args: []string{"array-option", "I", "II", "III"}, + config: testConfig, + expOut: `Added '[I II III]' to 'array-option' in context 'other_context' +active_context = "test_context" [preferences] debug = true @@ -114,15 +202,17 @@ func TestAdd(t *testing.T) { name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] + array_option = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] [[contexts]] name = "other_context" token = "another super secret token" [contexts.preferences] - default_ssh_keys = ["I", "II", "III"] + array_option = ["I", "II", "III"] poll_interval = "1.234s" `, }, @@ -137,7 +227,7 @@ func TestAdd(t *testing.T) { defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewAddCommand(fx.State()) diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index 7a0c1c9f..414b1bb8 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -57,7 +57,3 @@ on disk, run 'hcloud config get config'. ) return cmd } - -func gen() { - -} diff --git a/internal/cmd/config/get_test.go b/internal/cmd/config/get_test.go index 773a0fb3..bc725a1b 100644 --- a/internal/cmd/config/get_test.go +++ b/internal/cmd/config/get_test.go @@ -6,13 +6,49 @@ import ( "github.com/stretchr/testify/assert" configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" ) func TestGet(t *testing.T) { + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + testConfig := ` +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { key string args []string + err string expOut string expErr string } @@ -35,14 +71,29 @@ func TestGet(t *testing.T) { expOut: "1.234s\n", }, { - key: "default-ssh-keys", - expOut: "[1 2 3]\n", + key: "deeply.nested.option", + expOut: "bar\n", + }, + { + key: "non-existing-key", + err: "unknown key: non-existing-key", + expErr: "Error: unknown key: non-existing-key\n", + }, + { + key: "token", + err: "'token' is sensitive. use --allow-sensitive to show the value", + expErr: "Error: 'token' is sensitive. use --allow-sensitive to show the value\n", + }, + { + key: "token", + args: []string{"--allow-sensitive"}, + expOut: "super secret token\n", }, } for _, tt := range testCases { t.Run(tt.key, func(t *testing.T) { - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(testConfig)) defer fx.Finish() cmd := configCmd.NewGetCommand(fx.State()) @@ -51,7 +102,11 @@ func TestGet(t *testing.T) { setTestValues(fx.Config) out, errOut, err := fx.Run(cmd, append(tt.args, tt.key)) - assert.NoError(t, err) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.err) + } assert.Equal(t, tt.expErr, errOut) assert.Equal(t, tt.expOut, out) }) diff --git a/internal/cmd/config/list.go b/internal/cmd/config/list.go index c70e83f7..82aec349 100644 --- a/internal/cmd/config/list.go +++ b/internal/cmd/config/list.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/hetznercloud/cli/internal/cmd/output" "github.com/hetznercloud/cli/internal/cmd/util" @@ -13,7 +12,7 @@ import ( "github.com/hetznercloud/cli/internal/state/config" ) -var outputColumns = []string{"key", "value", "origin"} +var outputColumns = []string{"key", "value"} func NewListCommand(s state.State) *cobra.Command { cmd := &cobra.Command{ @@ -49,9 +48,8 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { } type option struct { - Key string `json:"key"` - Value any `json:"value"` - Origin string `json:"origin"` + Key string `json:"key"` + Value any `json:"value"` } var options []option @@ -63,7 +61,7 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { if !all && !opt.Changed(s.Config()) { continue } - options = append(options, option{name, val, originToString(s.Config().Viper().Origin(name))}) + options = append(options, option{name, val}) } // Sort options for reproducible output @@ -95,20 +93,3 @@ func runList(s state.State, cmd *cobra.Command, _ []string) error { } return t.Flush() } - -func originToString(orig viper.ValueOrigin) string { - switch orig { - case viper.ValueOriginFlag: - return "flag" - case viper.ValueOriginEnv: - return "environment" - case viper.ValueOriginConfig: - return "config file" - case viper.ValueOriginKVStore: - return "key-value store" - case viper.ValueOriginOverride: - return "override" - default: - return "default" - } -} diff --git a/internal/cmd/config/list_test.go b/internal/cmd/config/list_test.go index 74b65a29..19693dce 100644 --- a/internal/cmd/config/list_test.go +++ b/internal/cmd/config/list_test.go @@ -12,6 +12,40 @@ import ( ) func TestList(t *testing.T) { + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + testConfig := ` +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { name string args []string @@ -23,52 +57,52 @@ func TestList(t *testing.T) { { name: "default", args: []string{}, - expOut: `KEY VALUE ORIGIN -context test_context config file -debug yes environment -default-ssh-keys [1 2 3] config file -endpoint https://test-endpoint.com flag -poll-interval 1.234s environment -quiet yes flag -token [redacted] config file + expOut: `KEY VALUE +context test_context +debug yes +deeply.nested.option bar +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token [redacted] `, }, { - name: "no origin", - args: []string{"-o=columns=key,value"}, - expOut: `KEY VALUE -context test_context -debug yes -default-ssh-keys [1 2 3] -endpoint https://test-endpoint.com -poll-interval 1.234s -quiet yes -token [redacted] + name: "only key", + args: []string{"-o=columns=key"}, + expOut: `KEY +context +debug +deeply.nested.option +endpoint +poll-interval +quiet +token `, }, { name: "no header", args: []string{"-o=noheader"}, - expOut: `context test_context config file -debug yes environment -default-ssh-keys [1 2 3] config file -endpoint https://test-endpoint.com flag -poll-interval 1.234s environment -quiet yes flag -token [redacted] config file + expOut: `context test_context +debug yes +deeply.nested.option bar +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token [redacted] `, }, { name: "allow sensitive", args: []string{"--allow-sensitive"}, - expOut: `KEY VALUE ORIGIN -context test_context config file -debug yes environment -default-ssh-keys [1 2 3] config file -endpoint https://test-endpoint.com flag -poll-interval 1.234s environment -quiet yes flag -token super secret token config file + expOut: `KEY VALUE +context test_context +debug yes +deeply.nested.option bar +endpoint https://test-endpoint.com +poll-interval 1.234s +quiet yes +token super secret token `, }, { @@ -78,42 +112,31 @@ token super secret token config file "options": [ { "key": "context", - "value": "test_context", - "origin": "config file" + "value": "test_context" }, { "key": "debug", - "value": true, - "origin": "environment" + "value": true }, { - "key": "default-ssh-keys", - "value": [ - "1", - "2", - "3" - ], - "origin": "config file" + "key": "deeply.nested.option", + "value": "bar" }, { "key": "endpoint", - "value": "https://test-endpoint.com", - "origin": "flag" + "value": "https://test-endpoint.com" }, { "key": "poll-interval", - "value": 1234000000, - "origin": "environment" + "value": 1234000000 }, { "key": "quiet", - "value": true, - "origin": "flag" + "value": true }, { "key": "token", - "value": "[redacted]", - "origin": "config file" + "value": "[redacted]" } ] } @@ -123,7 +146,7 @@ token super secret token config file for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(testConfig)) defer fx.Finish() cmd := configCmd.NewListCommand(fx.State()) diff --git a/internal/cmd/config/remove.go b/internal/cmd/config/remove.go index 75e66bed..5659dde6 100644 --- a/internal/cmd/config/remove.go +++ b/internal/cmd/config/remove.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "reflect" "github.com/spf13/cobra" @@ -50,7 +51,6 @@ func runRemove(s state.State, cmd *cobra.Command, args []string) error { var ( removed []any - err error ctx config.Context prefs config.Preferences ) @@ -59,15 +59,34 @@ func runRemove(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global to remove an option globally)") } prefs = ctx.Preferences() } key, values := args[0], args[1:] - if removed, err = prefs.Remove(key, values); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) + } + + val, _ := prefs.Get(key) + + switch opt.T().(type) { + case []string: + before := util.AnyToStringSlice(val) + diff := util.SliceDiff[[]string](before, values) + val = diff + removed = util.ToAnySlice(util.SliceDiff[[]string](before, diff)) + default: + return fmt.Errorf("%s is not a list", key) + } + + if reflect.ValueOf(val).Len() == 0 { + prefs.Unset(key) + } else { + prefs.Set(key, val) } if len(removed) == 0 { @@ -76,7 +95,7 @@ func runRemove(s state.State, cmd *cobra.Command, args []string) error { _, _ = fmt.Fprintln(os.Stderr, "Warning: some values were not removed") } - if ctx == nil { + if util.IsNil(ctx) { cmd.Printf("Removed '%v' from '%s' globally\n", removed, key) } else { cmd.Printf("Removed '%v' from '%s' in context '%s'\n", removed, key, ctx.Name()) diff --git a/internal/cmd/config/remove_test.go b/internal/cmd/config/remove_test.go index 890d5c46..545809eb 100644 --- a/internal/cmd/config/remove_test.go +++ b/internal/cmd/config/remove_test.go @@ -6,24 +6,134 @@ import ( "github.com/stretchr/testify/assert" configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" ) func TestRemove(t *testing.T) { + + _, deleteArrayOption := config.NewTestOption[[]string]( + "array-option", + "array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteArrayOption() + + _, deleteNestedArrayOption := config.NewTestOption[[]string]( + "nested.array-option", + "nested array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteNestedArrayOption() + + testConfig := ` +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + array_option = ["1", "2", "3"] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { name string args []string + config string expOut string expErr string + err string preRun func() postRun func() } testCases := []testCase{ { - name: "remove from existing", - args: []string{"default-ssh-keys", "2", "3"}, - expOut: `active_context = "test_context" + name: "remove from existing", + args: []string{"array-option", "2", "3"}, + config: testConfig, + expOut: `Removed '[2 3]' from 'array-option' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + array_option = ["1"] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "remove all from existing", + args: []string{"array-option", "1", "2", "3"}, + config: testConfig, + expOut: `Removed '[1 2 3]' from 'array-option' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.nested] + array_option = ["1", "2", "3"] + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "remove from non-existing", + args: []string{"i-do-not-exist", "1", "2", "3"}, + config: testConfig, + err: "unknown preference: i-do-not-exist", + expErr: "Error: unknown preference: i-do-not-exist\n", + }, + { + name: "remove from nested", + args: []string{"nested.array-option", "2", "3"}, + config: testConfig, + expOut: `Removed '[2 3]' from 'nested.array-option' in context 'test_context' +active_context = "test_context" [preferences] debug = true @@ -33,9 +143,11 @@ func TestRemove(t *testing.T) { name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1"] + array_option = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.nested] + array_option = ["1"] [[contexts]] name = "other_context" @@ -45,9 +157,11 @@ func TestRemove(t *testing.T) { `, }, { - name: "remove all from existing", - args: []string{"default-ssh-keys", "1", "2", "3"}, - expOut: `active_context = "test_context" + name: "remove all from nested", + args: []string{"nested.array-option", "1", "2", "3"}, + config: testConfig, + expOut: `Removed '[1 2 3]' from 'nested.array-option' in context 'test_context' +active_context = "test_context" [preferences] debug = true @@ -57,6 +171,7 @@ func TestRemove(t *testing.T) { name = "test_context" token = "super secret token" [contexts.preferences] + array_option = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true @@ -67,6 +182,13 @@ func TestRemove(t *testing.T) { poll_interval = "1.234s" `, }, + { + name: "remove from non-list", + args: []string{"debug", "true"}, + config: testConfig, + err: "debug is not a list", + expErr: "Error: debug is not a list\n", + }, } for _, tt := range testCases { @@ -78,14 +200,18 @@ func TestRemove(t *testing.T) { defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewRemoveCommand(fx.State()) out, errOut, err := fx.Run(cmd, tt.args) - assert.NoError(t, err) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.err) + } assert.Equal(t, tt.expErr, errOut) assert.Equal(t, tt.expOut, out) }) diff --git a/internal/cmd/config/set.go b/internal/cmd/config/set.go index 1134b16c..2f563176 100644 --- a/internal/cmd/config/set.go +++ b/internal/cmd/config/set.go @@ -2,6 +2,9 @@ package config import ( "fmt" + "slices" + "strings" + "time" "github.com/spf13/cobra" @@ -49,7 +52,6 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { var ( val any - err error ctx config.Context prefs config.Preferences ) @@ -58,18 +60,59 @@ func runSet(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global flag to set a global option)") } prefs = ctx.Preferences() } key, values := args[0], args[1:] - if val, err = prefs.Set(key, values); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) } - if ctx == nil { + switch t := opt.T().(type) { + case bool: + if len(values) != 1 { + return fmt.Errorf("expected exactly one value") + } + value := values[0] + switch strings.ToLower(value) { + case "true", "t", "yes", "y", "1": + val = true + case "false", "f", "no", "n", "0": + val = false + default: + return fmt.Errorf("invalid boolean value: %s", value) + } + case string: + if len(values) != 1 { + return fmt.Errorf("expected exactly one value") + } + val = values[0] + case time.Duration: + if len(values) != 1 { + return fmt.Errorf("expected exactly one value") + } + value := values[0] + var err error + val, err = time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid duration value: %s", value) + } + case []string: + newVal := values[:] + slices.Sort(newVal) + newVal = slices.Compact(newVal) + val = newVal + default: + return fmt.Errorf("unsupported type %T", t) + } + + prefs.Set(key, val) + + if util.IsNil(ctx) { cmd.Printf("Set '%s' to '%v' globally\n", key, val) } else { cmd.Printf("Set '%s' to '%v' in context '%s'\n", key, val, ctx.Name()) diff --git a/internal/cmd/config/set_test.go b/internal/cmd/config/set_test.go index 506de6df..fe8b7d91 100644 --- a/internal/cmd/config/set_test.go +++ b/internal/cmd/config/set_test.go @@ -7,23 +7,75 @@ import ( "github.com/stretchr/testify/assert" configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" ) func TestSet(t *testing.T) { + _, deleteNestedOption := config.NewTestOption( + "nested.option", + "nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteNestedOption() + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + _, deleteArrayOption := config.NewTestOption[[]string]( + "array-option", + "array option", + nil, + config.OptionFlagPreference, + nil, + ) + defer deleteArrayOption() + + testConfig := ` +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { name string args []string + config string expOut string expErr string + err string preRun func() postRun func() } testCases := []testCase{ { - name: "set in current context", - args: []string{"debug-file", "debug.log"}, + name: "set in current context", + args: []string{"debug-file", "debug.log"}, + config: testConfig, expOut: `Set 'debug-file' to 'debug.log' in context 'test_context' active_context = "test_context" @@ -36,7 +88,6 @@ active_context = "test_context" token = "super secret token" [contexts.preferences] debug_file = "debug.log" - default_ssh_keys = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true @@ -57,8 +108,9 @@ active_context = "test_context" postRun: func() { _ = os.Unsetenv("HCLOUD_CONTEXT") }, - args: []string{"default-ssh-keys", "a", "b", "c"}, - expOut: `Set 'default-ssh-keys' to '[a b c]' in context 'other_context' + args: []string{"debug", "false"}, + config: testConfig, + expOut: `Set 'debug' to 'false' in context 'other_context' active_context = "test_context" [preferences] @@ -69,7 +121,6 @@ active_context = "test_context" name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true @@ -77,13 +128,14 @@ active_context = "test_context" name = "other_context" token = "another super secret token" [contexts.preferences] - default_ssh_keys = ["a", "b", "c"] + debug = false poll_interval = "1.234s" `, }, { - name: "set globally", - args: []string{"--global", "poll-interval", "50ms"}, + name: "set globally", + args: []string{"--global", "poll-interval", "50ms"}, + config: testConfig, expOut: `Set 'poll-interval' to '50ms' globally active_context = "test_context" @@ -95,7 +147,6 @@ active_context = "test_context" name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true @@ -104,6 +155,111 @@ active_context = "test_context" token = "another super secret token" [contexts.preferences] poll_interval = "1.234s" +`, + }, + { + name: "set nested", + args: []string{"nested.option", "bar"}, + config: testConfig, + expOut: `Set 'nested.option' to 'bar' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.nested] + option = "bar" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "set deeply nested", + args: []string{"deeply.nested.option", "bar"}, + config: testConfig, + expOut: `Set 'deeply.nested.option' to 'bar' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "set array option", + args: []string{"array-option", "a", "b", "c"}, + config: testConfig, + expOut: `Set 'array-option' to '[a b c]' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + array_option = ["a", "b", "c"] + endpoint = "https://test-endpoint.com" + quiet = true + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "set non existing", + args: []string{"non-existing", "value"}, + config: testConfig, + expErr: "Error: unknown preference: non-existing\n", + err: "unknown preference: non-existing", + }, + { + name: "set non-preference", + args: []string{"token", "value"}, + config: testConfig, + expErr: "Error: unknown preference: token\n", + err: "unknown preference: token", + }, + { + name: "set in empty config global", + args: []string{"debug", "false", "--global"}, + expOut: `Set 'debug' to 'false' globally +active_context = "" + +[preferences] + debug = false `, }, } @@ -117,14 +273,18 @@ active_context = "test_context" defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewSetCommand(fx.State()) out, errOut, err := fx.Run(cmd, tt.args) - assert.NoError(t, err) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.err) + } assert.Equal(t, tt.expErr, errOut) assert.Equal(t, tt.expOut, out) }) diff --git a/internal/cmd/config/testdata/cli.toml b/internal/cmd/config/testdata/cli.toml deleted file mode 100644 index 0712693f..00000000 --- a/internal/cmd/config/testdata/cli.toml +++ /dev/null @@ -1,19 +0,0 @@ -active_context = "test_context" - -[preferences] - debug = true - poll_interval = "1.234s" - -[[contexts]] - name = "test_context" - token = "super secret token" - [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] - endpoint = "https://test-endpoint.com" - quiet = true - -[[contexts]] - name = "other_context" - token = "another super secret token" - [contexts.preferences] - poll_interval = "1.234s" diff --git a/internal/cmd/config/unset.go b/internal/cmd/config/unset.go index f1e5f89b..6c883347 100644 --- a/internal/cmd/config/unset.go +++ b/internal/cmd/config/unset.go @@ -42,8 +42,6 @@ func runUnset(s state.State, cmd *cobra.Command, args []string) error { global, _ := cmd.Flags().GetBool("global") var ( - ok bool - err error ctx config.Context prefs config.Preferences ) @@ -52,21 +50,24 @@ func runUnset(s state.State, cmd *cobra.Command, args []string) error { prefs = s.Config().Preferences() } else { ctx = s.Config().ActiveContext() - if ctx == nil { + if util.IsNil(ctx) { return fmt.Errorf("no active context (use --global flag to unset a global option)") } prefs = ctx.Preferences() } key := args[0] - if ok, err = prefs.Unset(key); err != nil { - return err + opt, ok := config.Options[key] + if !ok || !opt.HasFlag(config.OptionFlagPreference) { + return fmt.Errorf("unknown preference: %s", key) } + ok = prefs.Unset(key) + if !ok { _, _ = fmt.Fprintf(os.Stderr, "Warning: key '%s' was not set\n", key) } - if ctx == nil { + if util.IsNil(ctx) { cmd.Printf("Unset '%s' globally\n", key) } else { cmd.Printf("Unset '%s' in context '%s'\n", key, ctx.Name()) diff --git a/internal/cmd/config/unset_test.go b/internal/cmd/config/unset_test.go index 3857148c..2d5d6287 100644 --- a/internal/cmd/config/unset_test.go +++ b/internal/cmd/config/unset_test.go @@ -7,13 +7,60 @@ import ( "github.com/stretchr/testify/assert" configCmd "github.com/hetznercloud/cli/internal/cmd/config" + "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" ) func TestUnset(t *testing.T) { + + _, deleteNestedOption := config.NewTestOption( + "nested.option", + "nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteNestedOption() + + _, deleteDeeplyNestedOption := config.NewTestOption( + "deeply.nested.option", + "deeply nested option", + "foo", + config.OptionFlagPreference, + nil, + ) + defer deleteDeeplyNestedOption() + + testConfig := ` +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + [contexts.preferences.nested] + option = "foo" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +` + type testCase struct { name string args []string + config string expOut string expErr string err string @@ -23,8 +70,9 @@ func TestUnset(t *testing.T) { testCases := []testCase{ { - name: "unset in current context", - args: []string{"quiet"}, + name: "unset in current context", + args: []string{"quiet"}, + config: testConfig, expOut: `Unset 'quiet' in context 'test_context' active_context = "test_context" @@ -36,8 +84,12 @@ active_context = "test_context" name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] endpoint = "https://test-endpoint.com" + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + [contexts.preferences.nested] + option = "foo" [[contexts]] name = "other_context" @@ -56,7 +108,8 @@ active_context = "test_context" postRun: func() { _ = os.Unsetenv("HCLOUD_CONTEXT") }, - args: []string{"poll-interval"}, + args: []string{"poll-interval"}, + config: testConfig, expOut: `Unset 'poll-interval' in context 'other_context' active_context = "test_context" @@ -68,9 +121,13 @@ active_context = "test_context" name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + [contexts.preferences.nested] + option = "foo" [[contexts]] name = "other_context" @@ -78,8 +135,9 @@ active_context = "test_context" `, }, { - name: "unset globally", - args: []string{"debug", "--global"}, + name: "unset globally", + args: []string{"debug", "--global"}, + config: testConfig, expOut: `Unset 'debug' globally active_context = "test_context" @@ -90,9 +148,13 @@ active_context = "test_context" name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + [contexts.preferences.nested] + option = "foo" [[contexts]] name = "other_context" @@ -104,12 +166,14 @@ active_context = "test_context" { name: "unset non existing", args: []string{"non-existing"}, + config: testConfig, err: "unknown preference: non-existing", expErr: "Error: unknown preference: non-existing\n", }, { name: "unset not set", args: []string{"debug-file"}, + config: testConfig, expErr: "Warning: key 'debug-file' was not set\n", expOut: `Unset 'debug-file' in context 'test_context' active_context = "test_context" @@ -122,9 +186,68 @@ active_context = "test_context" name = "test_context" token = "super secret token" [contexts.preferences] - default_ssh_keys = ["1", "2", "3"] endpoint = "https://test-endpoint.com" quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + [contexts.preferences.nested] + option = "foo" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "unset nested", + args: []string{"nested.option"}, + config: testConfig, + expOut: `Unset 'nested.option' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.deeply] + [contexts.preferences.deeply.nested] + option = "bar" + +[[contexts]] + name = "other_context" + token = "another super secret token" + [contexts.preferences] + poll_interval = "1.234s" +`, + }, + { + name: "unset deeply nested", + args: []string{"deeply.nested.option"}, + config: testConfig, + expOut: `Unset 'deeply.nested.option' in context 'test_context' +active_context = "test_context" + +[preferences] + debug = true + poll_interval = "1.234s" + +[[contexts]] + name = "test_context" + token = "super secret token" + [contexts.preferences] + endpoint = "https://test-endpoint.com" + quiet = true + [contexts.preferences.nested] + option = "foo" [[contexts]] name = "other_context" @@ -144,7 +267,7 @@ active_context = "test_context" defer tt.postRun() } - fx := testutil.NewFixtureWithConfigFile(t, "testdata/cli.toml") + fx := testutil.NewFixtureWithConfigFile(t, []byte(tt.config)) defer fx.Finish() cmd := configCmd.NewUnsetCommand(fx.State()) diff --git a/internal/cmd/context/active.go b/internal/cmd/context/active.go index 80b9b286..96584ada 100644 --- a/internal/cmd/context/active.go +++ b/internal/cmd/context/active.go @@ -26,7 +26,7 @@ func runActive(s state.State, cmd *cobra.Command, _ []string) error { if os.Getenv("HCLOUD_TOKEN") != "" { _, _ = fmt.Fprintln(os.Stderr, "Warning: HCLOUD_TOKEN is set. The active context will have no effect.") } - if ctx := s.Config().ActiveContext(); ctx != nil { + if ctx := s.Config().ActiveContext(); !util.IsNil(ctx) { cmd.Println(ctx.Name()) } return nil diff --git a/internal/cmd/server/ssh.go b/internal/cmd/server/ssh.go index 7990f2a8..ce4085cc 100644 --- a/internal/cmd/server/ssh.go +++ b/internal/cmd/server/ssh.go @@ -14,9 +14,10 @@ import ( "github.com/hetznercloud/cli/internal/cmd/util" "github.com/hetznercloud/cli/internal/hcapi2" "github.com/hetznercloud/cli/internal/state" - "github.com/hetznercloud/cli/internal/state/config" ) +var SSHPath = "ssh" + var SSHCmd = base.Cmd{ BaseCobraCommand: func(client hcapi2.Client) *cobra.Command { cmd := &cobra.Command{ @@ -58,7 +59,7 @@ var SSHCmd = base.Cmd{ } sshArgs := []string{"-l", user, "-p", strconv.Itoa(port), ipAddress.String()} - sshCommand := exec.Command(config.OptionSSHPath.Get(s.Config()), append(sshArgs, args[1:]...)...) + sshCommand := exec.Command(SSHPath, append(sshArgs, args[1:]...)...) sshCommand.Stdin = os.Stdin sshCommand.Stdout = os.Stdout sshCommand.Stderr = os.Stderr diff --git a/internal/cmd/server/ssh_test.go b/internal/cmd/server/ssh_test.go index a9bc5c4d..5fe57cf4 100644 --- a/internal/cmd/server/ssh_test.go +++ b/internal/cmd/server/ssh_test.go @@ -7,7 +7,6 @@ import ( "github.com/golang/mock/gomock" "github.com/hetznercloud/cli/internal/cmd/server" - "github.com/hetznercloud/cli/internal/state/config" "github.com/hetznercloud/cli/internal/testutil" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) @@ -29,7 +28,7 @@ func TestSSH(t *testing.T) { Get(gomock.Any(), srv.Name). Return(&srv, nil, nil) - config.OptionSSHPath.Override(fx.Config, "echo") + server.SSHPath = "echo" } testutil.TestCommand(t, &server.SSHCmd, map[string]testutil.TestCase{ diff --git a/internal/cmd/util/util.go b/internal/cmd/util/util.go index 7e7e1f10..d37673db 100644 --- a/internal/cmd/util/util.go +++ b/internal/cmd/util/util.go @@ -266,3 +266,16 @@ func ToAnySlice[T any](a []T) []any { } return s } + +func IsNil(v any) bool { + if v == nil { + return true + } + val := reflect.ValueOf(v) + switch val.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return val.IsNil() + default: + return false + } +} diff --git a/internal/cmd/util/util_test.go b/internal/cmd/util/util_test.go index a90e0ba1..7cfda220 100644 --- a/internal/cmd/util/util_test.go +++ b/internal/cmd/util/util_test.go @@ -271,3 +271,18 @@ func TestToKebabCase(t *testing.T) { assert.Equal(t, "foo-bar", util.ToKebabCase("Foo Bar")) assert.Equal(t, "foo", util.ToKebabCase("Foo")) } + +func TestIsNil(t *testing.T) { + assert.True(t, util.IsNil(nil)) + assert.True(t, util.IsNil((*int)(nil))) + assert.True(t, util.IsNil((chan int)(nil))) + assert.True(t, util.IsNil((map[int]int)(nil))) + assert.True(t, util.IsNil(([]int)(nil))) + assert.True(t, util.IsNil((func())(nil))) + assert.True(t, util.IsNil((interface{})(nil))) + assert.True(t, util.IsNil((error)(nil))) + assert.False(t, util.IsNil(0)) + assert.False(t, util.IsNil("")) + assert.False(t, util.IsNil([]int{})) + assert.False(t, util.IsNil(struct{}{})) +} diff --git a/internal/state/config/config.go b/internal/state/config/config.go index 294541e3..97f2fed2 100644 --- a/internal/state/config/config.go +++ b/internal/state/config/config.go @@ -11,6 +11,8 @@ import ( "github.com/BurntSushi/toml" "github.com/spf13/pflag" "github.com/spf13/viper" + + "github.com/hetznercloud/cli/internal/cmd/util" ) type Config interface { @@ -28,6 +30,7 @@ type Config interface { Preferences() Preferences Viper() *viper.Viper FlagSet() *pflag.FlagSet + Path() string } type schema struct { @@ -205,10 +208,16 @@ func (cfg *config) ActiveContext() Context { } func (cfg *config) SetActiveContext(ctx Context) { + if util.IsNil(ctx) { + cfg.activeContext = nil + cfg.schema.ActiveContext = "" + return + } if ctx, ok := ctx.(*context); !ok { panic("invalid context type") } else { cfg.activeContext = ctx + cfg.schema.ActiveContext = ctx.ContextName } } @@ -229,6 +238,7 @@ func (cfg *config) SetContexts(contexts []Context) { cfg.contexts = append(cfg.contexts, c) } } + cfg.schema.Contexts = cfg.contexts } func (cfg *config) Preferences() Preferences { @@ -246,3 +256,7 @@ func (cfg *config) Viper() *viper.Viper { func (cfg *config) FlagSet() *pflag.FlagSet { return cfg.fs } + +func (cfg *config) Path() string { + return cfg.path +} diff --git a/internal/state/config/options.go b/internal/state/config/options.go index db9786c2..1067a474 100644 --- a/internal/state/config/options.go +++ b/internal/state/config/options.go @@ -15,7 +15,6 @@ import ( type OptionFlag int -// [⚠️] If you add an option, don't forget to document it in internal/cmd/config/config.go const ( // OptionFlagPreference indicates that the option can be set in the config file, globally or per context (in the preferences section) OptionFlagPreference OptionFlag = 1 << iota @@ -120,29 +119,13 @@ var ( DefaultPreferenceFlags, nil, ) - - OptionDefaultSSHKeys = newOpt( - "default-ssh-keys", - "Default SSH keys for new servers", - []string{}, - DefaultPreferenceFlags&^OptionFlagPFlag, - nil, - ) - - OptionSSHPath = newOpt( - "ssh-path", - "Path to the SSH binary (used by 'hcloud server ssh')", - "ssh", - DefaultPreferenceFlags, - nil, - ) ) type Option[T any] struct { Name string Description string Default T - Source OptionFlag + Flags OptionFlag overrides *overrides } @@ -194,7 +177,7 @@ func (o *Option[T]) Changed(c Config) bool { } func (o *Option[T]) HasFlag(src OptionFlag) bool { - return o.Source&src != 0 + return o.Flags&src != 0 } func (o *Option[T]) IsSlice() bool { @@ -271,8 +254,16 @@ func (o *Option[T]) addToFlagSet(fs *pflag.FlagSet) { } } -func newOpt[T any](name, description string, def T, source OptionFlag, ov *overrides) *Option[T] { - o := &Option[T]{Name: name, Description: description, Default: def, Source: source, overrides: ov} +func newOpt[T any](name, description string, def T, flags OptionFlag, ov *overrides) *Option[T] { + o := &Option[T]{Name: name, Description: description, Default: def, Flags: flags, overrides: ov} Options[name] = o return o } + +// NewTestOption is a helper function to create an option for testing purposes +func NewTestOption[T any](name, description string, def T, flags OptionFlag, ov *overrides) (*Option[T], func()) { + opt := newOpt(name, description, def, flags, ov) + return opt, func() { + delete(Options, name) + } +} diff --git a/internal/state/config/preferences.go b/internal/state/config/preferences.go index db6a5cf9..adfa3efa 100644 --- a/internal/state/config/preferences.go +++ b/internal/state/config/preferences.go @@ -3,135 +3,79 @@ package config import ( "bytes" "fmt" - "reflect" - "slices" "strings" - "time" "github.com/BurntSushi/toml" "github.com/spf13/viper" - - "github.com/hetznercloud/cli/internal/cmd/util" ) // Preferences are options that can be set in the config file, globally or per context type Preferences map[string]any -func (p Preferences) Set(key string, values []string) (any, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return nil, fmt.Errorf("unknown preference: %s", key) - } - - var val any - switch t := opt.T().(type) { - case bool: - if len(values) != 1 { - return nil, fmt.Errorf("expected exactly one value") - } - value := values[0] - switch strings.ToLower(value) { - case "true", "t", "yes", "y", "1": - val = true - case "false", "f", "no", "n", "0": - val = false - default: - return nil, fmt.Errorf("invalid boolean value: %s", value) - } - case string: - if len(values) != 1 { - return nil, fmt.Errorf("expected exactly one value") - } - val = values[0] - case time.Duration: - if len(values) != 1 { - return nil, fmt.Errorf("expected exactly one value") +func (p Preferences) Get(key string) (any, bool) { + configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") + var m map[string]any = p + path := strings.Split(configKey, ".") + for i, key := range path { + if i == len(path)-1 { + val, ok := m[key] + return val, ok } - value := values[0] - var err error - val, err = time.ParseDuration(value) - if err != nil { - return nil, fmt.Errorf("invalid duration value: %s", value) + if next, ok := m[key].(map[string]any); !ok { + break + } else { + m = next } - case []string: - newVal := values[:] - slices.Sort(newVal) - newVal = slices.Compact(newVal) - val = newVal - default: - return nil, fmt.Errorf("unsupported type %T", t) - } - - configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - - p[configKey] = val - return val, nil -} - -func (p Preferences) Unset(key string) (bool, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return false, fmt.Errorf("unknown preference: %s", key) } - - configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - _, ok = p[configKey] - delete(p, configKey) - return ok, nil + return nil, false } -func (p Preferences) Add(key string, values []string) ([]any, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return nil, fmt.Errorf("unknown preference: %s", key) - } - - var added []any - +func (p Preferences) Set(key string, val any) { configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - val := p[configKey] - switch opt.T().(type) { - case []string: - before := util.AnyToStringSlice(val) - newVal := append(before, values...) - slices.Sort(newVal) - newVal = slices.Compact(newVal) - val = newVal - added = util.ToAnySlice(util.SliceDiff[[]string](newVal, before)) - default: - return nil, fmt.Errorf("%s is not a list", key) + var m map[string]any = p + path := strings.Split(configKey, ".") + for i, key := range path { + if i == len(path)-1 { + m[key] = val + return + } + if next, ok := m[key].(map[string]any); !ok { + next = make(map[string]any) + m[key] = next + m = next + } else { + m = next + } } - - p[configKey] = val - return added, nil } -func (p Preferences) Remove(key string, values []string) ([]any, error) { - opt, ok := Options[key] - if !ok || !opt.HasFlag(OptionFlagPreference) { - return nil, fmt.Errorf("unknown preference: %s", key) - } - - var removed []any - +func (p Preferences) Unset(key string) bool { configKey := strings.ReplaceAll(strings.ToLower(key), "-", "_") - val := p[configKey] - switch opt.T().(type) { - case []string: - before := util.AnyToStringSlice(val) - diff := util.SliceDiff[[]string](before, values) - val = diff - removed = util.ToAnySlice(util.SliceDiff[[]string](before, diff)) - default: - return nil, fmt.Errorf("%s is not a list", key) - } - - if reflect.ValueOf(val).Len() == 0 { - delete(p, configKey) - } else { - p[configKey] = val + var m map[string]any = p + path := strings.Split(configKey, ".") + parents := make([]map[string]any, 0, len(path)-1) + for i, key := range path { + parents = append(parents, m) + if i == len(path)-1 { + _, ok := m[key] + delete(m, key) + // delete parent maps if they are empty + for i := len(parents) - 1; i >= 0; i-- { + if len(parents[i]) == 0 { + if i > 0 { + delete(parents[i-1], path[i-1]) + } + } + } + return ok + } + if next, ok := m[key].(map[string]any); !ok { + return false + } else { + m = next + } } - return removed, nil + return false } func (p Preferences) merge(v *viper.Viper) error { @@ -151,13 +95,22 @@ func (p Preferences) merge(v *viper.Viper) error { } func (p Preferences) validate() error { - for key := range p { - opt, ok := Options[strings.ReplaceAll(key, "_", "-")] + return validate(p, "") +} + +func validate(m map[string]any, prefix string) error { + for configKey, val := range m { + key := prefix + strings.ReplaceAll(configKey, "_", "-") + if val, ok := val.(map[string]any); ok { + if err := validate(val, key+"."); err != nil { + return err + } + continue + } + opt, ok := Options[key] if !ok || !opt.HasFlag(OptionFlagPreference) { return fmt.Errorf("unknown preference: %s", key) } } return nil } - -var _ Preferences = Preferences{} diff --git a/internal/state/config/preferences_test.go b/internal/state/config/preferences_test.go index 75778632..287ca79e 100644 --- a/internal/state/config/preferences_test.go +++ b/internal/state/config/preferences_test.go @@ -9,7 +9,7 @@ import ( func TestUnknownPreference(t *testing.T) { t.Run("existing", func(t *testing.T) { clear(Options) - newOpt("foo", "", "", OptionFlagPreference) + newOpt("foo", "", "", OptionFlagPreference, nil) p := Preferences{"foo": ""} assert.NoError(t, p.validate()) @@ -17,7 +17,7 @@ func TestUnknownPreference(t *testing.T) { t.Run("existing but no preference", func(t *testing.T) { clear(Options) - newOpt("foo", "", "", 0) + newOpt("foo", "", "", 0, nil) p := Preferences{"foo": ""} assert.EqualError(t, p.validate(), "unknown preference: foo") diff --git a/internal/state/helpers.go b/internal/state/helpers.go index c9a9ebc2..7ff91ff7 100644 --- a/internal/state/helpers.go +++ b/internal/state/helpers.go @@ -4,6 +4,8 @@ import ( "errors" "github.com/spf13/cobra" + + "github.com/hetznercloud/cli/internal/state/config" ) func Wrap(s State, f func(State, *cobra.Command, []string) error) func(*cobra.Command, []string) error { diff --git a/internal/testutil/fixture.go b/internal/testutil/fixture.go index d90c3226..60c52b85 100644 --- a/internal/testutil/fixture.go +++ b/internal/testutil/fixture.go @@ -26,7 +26,7 @@ type Fixture struct { // NewFixture creates a new Fixture with default config file. func NewFixture(t *testing.T) *Fixture { - return NewFixtureWithConfigFile(t, nil) + return NewFixtureWithConfigFile(t, []byte{}) } // NewFixtureWithConfigFile creates a new Fixture with the given config file.