diff --git a/README.md b/README.md index 24b0592..5e260f7 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,22 @@ API key resolution order (highest precedence first): Keys are obtained from the [Two merchant portal](https://portal.two.inc). +### Configuration directory + +Set `TWOCTL_CONFIG_HOME` to use a dedicated twoctl configuration directory: + +```sh +TWOCTL_CONFIG_HOME=/path/to/job-config twoctl config get-contexts +``` + +Directory precedence is `TWOCTL_CONFIG_HOME`, then +`$XDG_CONFIG_HOME/twoctl`, then `~/.config/twoctl`. An empty override falls +back to the next location. Both `config.yaml` (and its lock) and updater +`state.json` use the selected directory; `TWOCTL_CONFIG_HOME` names that +directory directly, with no extra `twoctl/` appended. Existing files are +not copied between locations. API keys stay in the OS keychain, keyed by +context name, so selecting a different directory does not isolate keys. + ## Usage The command tree is resource-first: `twoctl [flags]`. diff --git a/internal/config/config.go b/internal/config/config.go index 41a3c3b..240a7d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -181,11 +181,13 @@ func keyringAccount(ctxName string) string { return "ctx:" + ctxName } // --- file I/O --- -// configDir returns the twoctl config directory. We prefer XDG-style -// `$XDG_CONFIG_HOME/twoctl` or `~/.config/twoctl` over the macOS default -// (`~/Library/Application Support/twoctl`) so the location is predictable -// across platforms and matches where users expect dotfile-style config. +// configDir returns TWOCTL_CONFIG_HOME when set, otherwise the XDG-style +// $XDG_CONFIG_HOME/twoctl or ~/.config/twoctl directory. The override names +// the twoctl directory itself, not its parent. func configDir() (string, error) { + if dir := os.Getenv("TWOCTL_CONFIG_HOME"); dir != "" { + return dir, nil + } if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { return filepath.Join(x, "twoctl"), nil } diff --git a/internal/config/config_home_test.go b/internal/config/config_home_test.go new file mode 100644 index 0000000..f57d2a6 --- /dev/null +++ b/internal/config/config_home_test.go @@ -0,0 +1,71 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigHomePrecedence(t *testing.T) { + for _, source := range []string{"override", "xdg", "home"} { + t.Run(source, func(t *testing.T) { + dir := withTempHome(t) + t.Setenv("TWOCTL_CONFIG_HOME", "") + wantDir := filepath.Join(dir, "twoctl") + if source == "override" { + wantDir = filepath.Join(dir, "job config") + t.Setenv("TWOCTL_CONFIG_HOME", wantDir) + } + if source == "home" { + t.Setenv("XDG_CONFIG_HOME", "") + wantDir = filepath.Join(dir, ".config", "twoctl") + } + if err := SetContext("sandbox", "", ""); err != nil { + t.Fatal(err) + } + if err := UseContext("sandbox"); err != nil { + t.Fatal(err) + } + assertConfigFile(t, filepath.Join(wantDir, "config.yaml")) + if _, err := os.Stat(filepath.Join(wantDir, "config.yaml.lock")); err != nil { + t.Fatalf("lock is not in selected directory: %v", err) + } + cfg, err := LoadFile() + if err != nil || cfg.CurrentContext != "sandbox" { + t.Fatalf("config did not round-trip: %v, %v", cfg, err) + } + }) + } +} + +func assertConfigFile(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("config permissions = %o, want 600", info.Mode().Perm()) + } +} + +func TestConfigHomeDoesNotModifyDefaultConfig(t *testing.T) { + dir := withTempHome(t) + t.Setenv("TWOCTL_CONFIG_HOME", "") + if err := SetContext("prod", "", ""); err != nil { + t.Fatal(err) + } + t.Setenv("TWOCTL_CONFIG_HOME", filepath.Join(dir, "isolated")) + cfg, err := LoadFile() + if err != nil || len(cfg.Contexts) != 0 { + t.Fatalf("override should start empty: %v, %v", cfg, err) + } + if err := SetContext("sandbox", "", ""); err != nil { + t.Fatal(err) + } + t.Setenv("TWOCTL_CONFIG_HOME", "") + cfg, err = LoadFile() + if err != nil || cfg.CurrentContext != "prod" || len(cfg.Contexts) != 1 { + t.Fatalf("default config changed: %v, %v", cfg, err) + } +} diff --git a/internal/updater/config_home_test.go b/internal/updater/config_home_test.go new file mode 100644 index 0000000..eec5267 --- /dev/null +++ b/internal/updater/config_home_test.go @@ -0,0 +1,63 @@ +package updater + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStateConfigHomePrecedence(t *testing.T) { + for _, source := range []string{"override", "xdg", "home"} { + t.Run(source, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("TWOCTL_CONFIG_HOME", "") + wantDir := filepath.Join(dir, "twoctl") + if source == "override" { + wantDir = filepath.Join(dir, "job config") + t.Setenv("TWOCTL_CONFIG_HOME", wantDir) + } + if source == "home" { + t.Setenv("XDG_CONFIG_HOME", "") + wantDir = filepath.Join(dir, ".config", "twoctl") + } + if err := SaveState(&State{SkippedVersions: []string{"v1.0.0"}}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(wantDir, "state.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("state permissions = %o, want 600", info.Mode().Perm()) + } + state, err := LoadState() + if err != nil || !state.IsSkipped("v1.0.0") { + t.Fatalf("state did not round-trip: %v, %v", state, err) + } + }) + } +} + +func TestStateConfigHomeDoesNotModifyDefaultState(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("TWOCTL_CONFIG_HOME", "") + if err := SaveState(&State{SkippedVersions: []string{"v1.0.0"}}); err != nil { + t.Fatal(err) + } + t.Setenv("TWOCTL_CONFIG_HOME", filepath.Join(dir, "isolated")) + state, err := LoadState() + if err != nil || len(state.SkippedVersions) != 0 { + t.Fatalf("override should start empty: %v, %v", state, err) + } + if err := SaveState(&State{SkippedVersions: []string{"v2.0.0"}}); err != nil { + t.Fatal(err) + } + t.Setenv("TWOCTL_CONFIG_HOME", "") + state, err = LoadState() + if err != nil || !state.IsSkipped("v1.0.0") || state.IsSkipped("v2.0.0") { + t.Fatalf("default state changed: %v, %v", state, err) + } +} diff --git a/internal/updater/state.go b/internal/updater/state.go index 39e2bc3..3154d7a 100644 --- a/internal/updater/state.go +++ b/internal/updater/state.go @@ -65,9 +65,12 @@ func (s *State) AutoCheckEnabled() bool { return *s.AutoCheck } -// statePath uses the same XDG-flavoured directory as the rest of twoctl -// (~/.config/twoctl or $XDG_CONFIG_HOME/twoctl). +// statePath uses the same directory precedence as context configuration: +// TWOCTL_CONFIG_HOME, $XDG_CONFIG_HOME/twoctl, then ~/.config/twoctl. func statePath() (string, error) { + if dir := os.Getenv("TWOCTL_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "state.json"), nil + } if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { return filepath.Join(x, "twoctl", "state.json"), nil }