Skip to content
Merged
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <resource> <action> [flags]`.
Expand Down
10 changes: 6 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
71 changes: 71 additions & 0 deletions internal/config/config_home_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
63 changes: 63 additions & 0 deletions internal/updater/config_home_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 5 additions & 2 deletions internal/updater/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading