diff --git a/README.md b/README.md index deed222..d9f213e 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ arcli config set-active production arcli config update production --token-stdin ``` -Connection profiles live in `~/.arcli/config.toml` with file mode `0600`. For CI and one-off commands, use `ARC_CONNECTION`, `ARC_ENDPOINT`, and `ARC_TOKEN`, or pass `--connection` / `--endpoint` directly. +Connection profiles live in `~/.arcli/config.toml` with file mode `0600`. Profile names are case-sensitive and may contain dots. For CI and one-off commands, use `ARC_CONNECTION`, `ARC_ENDPOINT`, and `ARC_TOKEN`, or pass `--connection` / `--endpoint` directly. ### Query and write diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index b75bbe1..360086b 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -5,3 +5,7 @@ - All successfully read license states exit 0. Configuration, request, decoding, and output failures exit nonzero. No token or database header is sent to `/health`, and no authentication verification is performed. Normal connection selection, timeout, TLS verification, and identity opt-out settings apply. License installation and key/file configuration remain server responsibilities. Existing `ping -o json` license pass-through is unchanged. Closes [arcli#34](https://github.com/Basekick-Labs/arcli/issues/34). + +### Fixed + +- Preserve connection profile names when reading and writing config files. Mixed-case names remain selectable, dots are treated literally, and profiles whose names differ only in case no longer overwrite each other. diff --git a/go.mod b/go.mod index 5a90d90..ef5671e 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.25.6 require ( github.com/olekukonko/tablewriter v1.1.5 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/spf13/viper v1.21.0 ) require ( @@ -15,23 +15,16 @@ require ( github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.6 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.28.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index 1dbeb58..0837705 100644 --- a/go.sum +++ b/go.sum @@ -6,18 +6,9 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -40,40 +31,21 @@ github.com/olekukonko/tablewriter v1.1.5 h1:4LoZSfMySpMQY3PT8RWJsJeuEuMIoo9xGRgv github.com/olekukonko/tablewriter v1.1.5/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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= -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/commands/config_test.go b/internal/commands/config_test.go new file mode 100644 index 0000000..0cbf042 --- /dev/null +++ b/internal/commands/config_test.go @@ -0,0 +1,63 @@ +package commands + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/basekick-labs/arcli/internal/config" +) + +func TestConfigConnectionNames(t *testing.T) { + t.Setenv("ARCLI_CONFIG", filepath.Join(t.TempDir(), "config.toml")) + t.Setenv("ARC_CONNECTION", "") + t.Setenv("ARC_ENDPOINT", "") + t.Setenv("ARC_TOKEN", "") + + // Case variants must coexist, and dots must remain part of the name. + names := []string{"Prod.EU", "prod.eu", "LOCAL"} + for _, name := range names { + _, _, err := execCmd(t, newConfigCreateCmd(), "--name", name, "--endpoint", "https://example.com") + if err != nil { + t.Fatalf("create %q: %v", name, err) + } + } + for _, name := range names { + _, _, err := execCmd(t, newConfigCreateCmd(), "--name", name, "--endpoint", "https://other.example.com") + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("duplicate %q: err=%v", name, err) + } + if _, _, err := execCmd(t, newConfigUpdateCmd(), name, "--default-database", name); err != nil { + t.Fatalf("update %q: %v", name, err) + } + } + for _, name := range names { + if _, _, err := execCmd(t, newConfigSetActiveCmd(), name); err != nil { + t.Fatalf("set active %q: %v", name, err) + } + out, _, err := execCmd(t, newConfigCurrentCmd()) + if err != nil || !strings.Contains(out, "name: "+name+"\n") { + t.Fatalf("current %q: out=%q err=%v", name, out, err) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + conn, gotName, err := cfg.Resolve(config.ResolveOptions{}) + if err != nil || gotName != name || conn.DefaultDatabase != name { + t.Fatalf("resolve %q: conn=%+v name=%q err=%v", name, conn, gotName, err) + } + } + for _, name := range names { + if _, _, err := execCmd(t, newConfigDeleteCmd(), name, "--yes"); err != nil { + t.Fatalf("delete %q: %v", name, err) + } + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + if len(cfg.Connections) != 0 || cfg.Active != "" { + t.Errorf("after deleting all connections: %+v", cfg) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 05dbd63..b4249ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,7 +27,7 @@ import ( "path/filepath" "strings" - "github.com/spf13/viper" + "github.com/pelletier/go-toml/v2" ) // Connection is one named Arc endpoint + its credentials. @@ -117,21 +117,17 @@ func Load() (*Config, error) { return nil, err } - if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + body, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { return &Config{Connections: map[string]Connection{}}, nil } else if err != nil { - return nil, fmt.Errorf("stat config: %w", err) - } - - v := viper.New() - v.SetConfigFile(path) - v.SetConfigType("toml") - if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } cfg := &Config{Connections: map[string]Connection{}} - if err := v.Unmarshal(cfg); err != nil { + // Connection names are literal, case-sensitive TOML keys. Viper + // lowercases them and treats dots as nested key separators. + if err := toml.Unmarshal(body, cfg); err != nil { return nil, fmt.Errorf("parse config %s: %w", path, err) } if cfg.Connections == nil { @@ -161,37 +157,32 @@ func (c *Config) Save() error { } c.InstallationID = id } - v := viper.New() - v.Set("active", c.Active) - v.Set("connections", c.Connections) - v.Set("installation_id", c.InstallationID) - if c.SendInstallationID != nil && !*c.SendInstallationID { + saved := *c + if saved.SendsInstallationID() { // Only the opt-out is written; the default stays implicit. - v.Set("send_installation_id", false) + saved.SendInstallationID = nil + } + body, err := toml.Marshal(saved) + if err != nil { + return fmt.Errorf("encode config: %w", err) } // Write to a temp file in the same directory then rename atomically, - // so we never leave a half-written config behind on a crash. Use a - // .toml extension on the temp path because viper.WriteConfigAs infers - // format from the extension and rejects unknown suffixes like .tmp. + // so we never leave a half-written config behind on a crash. tmp, err := os.CreateTemp(dir, "config.*.toml") if err != nil { return fmt.Errorf("create temp config: %w", err) } tmpPath := tmp.Name() - // Close immediately — viper.WriteConfigAs reopens with its own - // codec rather than appending to our handle. - _ = tmp.Close() - if err := v.WriteConfigAs(tmpPath); err != nil { - _ = os.Remove(tmpPath) + defer os.Remove(tmpPath) + if _, err := tmp.Write(body); err != nil { + _ = tmp.Close() return fmt.Errorf("write temp config: %w", err) } - if err := os.Chmod(tmpPath, 0o600); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("chmod temp config: %w", err) + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp config: %w", err) } if err := os.Rename(tmpPath, path); err != nil { - _ = os.Remove(tmpPath) return fmt.Errorf("rename config into place: %w", err) } return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 93df879..93bb7b3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "testing" ) @@ -97,6 +98,59 @@ func TestSaveAndLoad_Roundtrip(t *testing.T) { }) } +func TestSaveAndLoad_PreservesConnectionNames(t *testing.T) { + withTempConfig(t, func() { + clearEnv(t) + cfg := &Config{ + Active: "Prod.EU", + Connections: map[string]Connection{ + "Prod.EU": {Endpoint: "https://prod.example.com", Token: "prod-token", DefaultDatabase: "metrics", InsecureTLS: true}, + "prod.eu": {Endpoint: "https://staging.example.com", Token: "staging-token"}, + "LOCAL": {Endpoint: "http://localhost:8000"}, + }, + } + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + got, err := Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Connections, cfg.Connections) { + t.Errorf("connections = %+v, want %+v", got.Connections, cfg.Connections) + } + conn, name, err := got.Resolve(ResolveOptions{}) + if err != nil || name != cfg.Active || conn != cfg.Connections[cfg.Active] { + t.Errorf("resolve active: conn=%+v name=%q err=%v", conn, name, err) + } + for name, want := range cfg.Connections { + conn, resolvedName, err := got.Resolve(ResolveOptions{ConnectionName: name}) + if err != nil || resolvedName != name || conn != want { + t.Errorf("resolve %q: conn=%+v name=%q err=%v", name, conn, resolvedName, err) + } + } + }) +} + +func TestLoad_PreservesQuotedConnectionNames(t *testing.T) { + withTempConfig(t, func() { + clearEnv(t) + path, _ := ConfigPath() + body := "active = 'Prod.EU'\n[connections.'Prod.EU']\nendpoint = 'https://prod.example.com'\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + conn, name, err := cfg.Resolve(ResolveOptions{}) + if err != nil || name != "Prod.EU" || conn.Endpoint != "https://prod.example.com" { + t.Errorf("resolve: conn=%+v name=%q err=%v", conn, name, err) + } + }) +} + func TestResolve_PrecedenceOrder(t *testing.T) { // The full precedence ladder, exercised in order. Each subtest sets // up exactly one signal and confirms it wins over anything lower.