Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 3 additions & 10 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
)
32 changes: 2 additions & 30 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
63 changes: 63 additions & 0 deletions internal/commands/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
47 changes: 19 additions & 28 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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.
Expand Down
Loading