diff --git a/README.md b/README.md index 5e260f7..8eadd32 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,19 @@ twoctl --context cyber checkout get-order --order-id abc # kubectl-style alias twoctl --url http://localhost:8080 --api-key secret_test_x ... ``` +Scripts can select a context without changing the saved selection: + +```sh +TWOCTL_CONTEXT=staging twoctl order get --order-id abc +``` + +Context selection follows `--context`, then `--env`, then non-empty +`TWOCTL_CONTEXT`, then the saved current context. `--url` overrides the +API URL and requires `--api-key` or `TWO_API_KEY` rather than using a stored +context key. API calls, `auth whoami`, `auth login`, `auth logout`, and +`--dry-run` honor the invocation context. `config current-context` continues +to show the saved selection; the environment override never writes it. + For built-in env names (`prod`, `sandbox`, `staging`, `cyber`, `perf`, `release`) twoctl knows the URL. For any other name it falls back to `https://api..two.inc`; override with `--url`. diff --git a/cmd/twoctl/cli/auth.go b/cmd/twoctl/cli/auth.go index 1b87eb1..d992386 100644 --- a/cmd/twoctl/cli/auth.go +++ b/cmd/twoctl/cli/auth.go @@ -20,7 +20,7 @@ func init() { Long: `Store, remove, and inspect the API key for a context. By default, auth subcommands operate on the current context (set by -` + "`twoctl config use-context`" + `). Pass --context to target a different one.`, +` + "`twoctl config use-context`" + `). Pass --context or set TWOCTL_CONTEXT to target a different one.`, } authCmd.AddCommand( authLoginCmd(), @@ -101,8 +101,12 @@ func authWhoamiCmd() *cobra.Command { } // pickContext returns the explicit override if provided, otherwise the -// current context name from config. Errors if neither is set. +// invocation context (--context / --env / TWOCTL_CONTEXT), then the saved +// context name. Errors if none is set. func pickContext(override string) (string, error) { + if override == "" { + override = activeEnv() + } if override != "" { return override, nil } diff --git a/cmd/twoctl/cli/context_env_test.go b/cmd/twoctl/cli/context_env_test.go new file mode 100644 index 0000000..36fffac --- /dev/null +++ b/cmd/twoctl/cli/context_env_test.go @@ -0,0 +1,151 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/two-inc/twoctl-cli/internal/config" +) + +func TestContextEnvironmentPrecedence(t *testing.T) { + cases := []struct { + name string + env string + flags []string + want string + }{ + {"saved", "", nil, "saved"}, + {"environment", "job", nil, "job"}, + {"env flag", "job", []string{"--env", "explicit"}, "explicit"}, + {"context flag", "job", []string{"--context", "explicit"}, "explicit"}, + {"both flags", "job", []string{"--env", "job", "--context", "explicit"}, "explicit"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetGlobals(t) + t.Setenv("TWOCTL_CONTEXT", tc.env) + t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1") + var path, key string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path, key = r.URL.Path, r.Header.Get("X-Api-Key") + fmt.Fprint(w, `{"items":[]}`) + })) + defer srv.Close() + for _, name := range []string{"saved", "job", "explicit"} { + if err := config.SetContext(name, srv.URL+"/"+name, name+"-key"); err != nil { + t.Fatal(err) + } + } + args := append(append([]string{}, tc.flags...), "billing-statement", "get") + if _, _, err := runRoot(t, args...); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(path, "/"+tc.want+"/") || key != tc.want+"-key" { + t.Fatalf("selected path=%q key=%q, want context %s", path, key, tc.want) + } + assertSavedContext(t, "saved") + }) + } +} + +func assertSavedContext(t *testing.T, want string) { + t.Helper() + cfg, err := config.LoadFile() + if err != nil { + t.Fatal(err) + } + if cfg.CurrentContext != want { + t.Fatalf("saved context changed to %q, want %q", cfg.CurrentContext, want) + } +} + +func TestContextEnvironmentDryRunWithoutKey(t *testing.T) { + resetGlobals(t) + t.Setenv("TWOCTL_CONTEXT", "job") + t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1") + if err := config.SetContext("job", "https://custom.example.test/api", ""); err != nil { + t.Fatal(err) + } + stdout, _, err := runRoot(t, "billing-statement", "get", "--dry-run") + if err != nil { + t.Fatal(err) + } + var request struct { + URL string `json:"url"` + Headers http.Header `json:"headers"` + } + if err := json.Unmarshal([]byte(stdout), &request); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(request.URL, "https://custom.example.test/api/") { + t.Fatalf("dry-run ignored the registered context URL: %s", request.URL) + } + if request.Headers.Get("X-Api-Key") != "" { + t.Fatal("dry-run injected a placeholder API key") + } +} + +func TestContextEnvironmentRawURLRequiresExplicitKey(t *testing.T) { + resetGlobals(t) + t.Setenv("TWOCTL_CONTEXT", "job") + t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1") + if err := config.SetContext("job", "", "context-key"); err != nil { + t.Fatal(err) + } + _, _, err := runRoot(t, "--url", "https://override.example.test", "billing-statement", "get") + if err != config.ErrNoAPIKey { + t.Fatalf("raw URL must not use an environment context's key: %v", err) + } +} + +func TestContextEnvironmentAuthCommands(t *testing.T) { + for _, command := range []string{"login", "logout", "whoami"} { + t.Run(command, func(t *testing.T) { + resetGlobals(t) + t.Setenv("TWOCTL_CONTEXT", "job") + t.Setenv("TWOCTL_SKIP_UPGRADE_CHECK", "1") + for _, name := range []string{"saved", "job"} { + if err := config.SetContext(name, "", "secret_test_abcdefghijklmnop"); err != nil { + t.Fatal(err) + } + } + args := []string{"auth", command} + if command == "login" { + args = append(args, "--key", "secret_test_zyxwvutsrqponmlk") + } + stdout, _, err := runRoot(t, args...) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, "job") { + t.Fatalf("auth %s did not target environment context: %s", command, stdout) + } + if command == "logout" && config.HasStoredKey("job") { + t.Fatal("logout did not remove the environment context's key") + } + if command == "login" { + resolved, err := config.Resolve("", "job", "") + if err != nil || resolved.APIKey != "secret_test_zyxwvutsrqponmlk" { + t.Fatalf("login did not update the environment context's key: %v", err) + } + } + if !config.HasStoredKey("saved") { + t.Fatal("auth command affected saved context's key") + } + assertSavedContext(t, "saved") + }) + } +} + +func TestAuthExplicitContextOverridesEnvironment(t *testing.T) { + resetGlobals(t) + t.Setenv("TWOCTL_CONTEXT", "job") + got, err := pickContext("explicit") + if err != nil || got != "explicit" { + t.Fatalf("explicit context lost precedence: %q, %v", got, err) + } +} diff --git a/cmd/twoctl/cli/operations.go b/cmd/twoctl/cli/operations.go index 2386000..ae122b5 100644 --- a/cmd/twoctl/cli/operations.go +++ b/cmd/twoctl/cli/operations.go @@ -366,7 +366,13 @@ func runOperation(c *cobra.Command, method, path string, op *openapi3.Operation, } func resolveForRun(dryRun bool) (*config.Resolved, error) { - resolved, err := config.Resolve(flagAPIKey, activeEnv(), flagURL) + key := flagAPIKey + if dryRun { + // Resolve the real context URL without reading a keychain entry. + // Dry-run output never uses this placeholder as an auth header. + key = "dry-run" + } + resolved, err := config.Resolve(key, activeEnv(), flagURL) if err == nil { return resolved, nil } diff --git a/cmd/twoctl/cli/operations_test.go b/cmd/twoctl/cli/operations_test.go index a2d6e98..cc1dd31 100644 --- a/cmd/twoctl/cli/operations_test.go +++ b/cmd/twoctl/cli/operations_test.go @@ -39,6 +39,7 @@ func resetGlobals(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", dir) t.Setenv("HOME", dir) t.Setenv("TWO_API_KEY", "") + t.Setenv("TWOCTL_CONTEXT", "") keyring.MockInit() } diff --git a/cmd/twoctl/cli/root.go b/cmd/twoctl/cli/root.go index d5da2ab..fe3e696 100644 --- a/cmd/twoctl/cli/root.go +++ b/cmd/twoctl/cli/root.go @@ -3,6 +3,8 @@ package cli import ( + "os" + "github.com/spf13/cobra" "github.com/two-inc/twoctl-cli/internal/httpx" @@ -18,12 +20,16 @@ var ( ) // activeEnv returns the context-selecting flag value, preferring --context -// over --env when both are set (kubectl-style precedence). +// over --env when both are set (kubectl-style precedence), then falling +// back to TWOCTL_CONTEXT for scripts without changing the saved context. func activeEnv() string { if flagContext != "" { return flagContext } - return flagEnv + if flagEnv != "" { + return flagEnv + } + return os.Getenv("TWOCTL_CONTEXT") } var rootCmd = &cobra.Command{