diff --git a/.circleci/config.yml b/.circleci/config.yml index 06b1f01f..e66e666e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,6 +76,14 @@ jobs: ./dist/chunk skill install echo "OK: skill content embedded correctly" head -5 ~/.claude/skills/chunk-review/SKILL.md + - run: + name: Cross-compile for all release targets + command: | + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + GOOS=${target%/*} GOARCH=${target#*/} CGO_ENABLED=0 go build -o /dev/null . \ + && echo "OK: $target" \ + || { echo "FAIL: $target"; exit 1; } + done workflows: ci: diff --git a/.circleci/release.yml b/.circleci/release.yml index ca7a4c97..24e4b2be 100644 --- a/.circleci/release.yml +++ b/.circleci/release.yml @@ -22,7 +22,7 @@ jobs: - run: name: Smoke test command: | - go build -o ~/chunk . + CGO_ENABLED=0 go build -o ~/chunk . ~/chunk --version - run: task release VERSION=<< pipeline.parameters.version >> - persist_to_workspace: diff --git a/acceptance/auth_test.go b/acceptance/auth_test.go index 91f1967d..b60ffbe7 100644 --- a/acceptance/auth_test.go +++ b/acceptance/auth_test.go @@ -147,35 +147,6 @@ func TestAuthStatusMaskExactlyFourChars(t *testing.T) { "expected chars 5-8 from end to be masked, got: %s", combined) } -// auth status reads key from config file when no env var is set -func TestAuthStatusFromConfigFile(t *testing.T) { - anthropic := fakes.NewFakeAnthropic() - srv := httptest.NewServer(anthropic) - defer srv.Close() - - env := testenv.NewTestEnv(t) - env.AnthropicURL = srv.URL - env.AnthropicKey = "" // no env var - env.CircleToken = "" // Anthropic-only test - env.GithubToken = "" - - // Store key in config file - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - assert.NilError(t, config.Save(config.UserConfig{AnthropicAPIKey: "sk-ant-config-only-XYZW"})) - - result := binary.RunCLI(t, []string{"auth", "status"}, env, env.HomeDir) - - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - combined := result.Stdout + result.Stderr - assert.Assert(t, strings.Contains(combined, "Config file"), - "expected config file source, got: %s", combined) - assert.Assert(t, strings.Contains(combined, "Valid"), - "expected key valid message, got: %s", combined) - // Last 4 chars visible - assert.Assert(t, strings.Contains(combined, "XYZW"), - "expected last 4 chars of key, got: %s", combined) -} - // auth status validates via /v1/messages/count_tokens, not /v1/messages func TestAuthStatusUsesCountTokensEndpoint(t *testing.T) { anthropic := fakes.NewFakeAnthropic() @@ -293,10 +264,11 @@ func TestAuthRemoveWithStoredKey(t *testing.T) { "expected removal prompt or --force suggestion, got: %s", combined) assert.Assert(t, strings.Contains(combined, env.HomeDir), "expected config path in output, got: %s", combined) - // Key should not have been removed — cancelled remove leaves config intact. - showResult := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, showResult.ExitCode, 0, "config show failed after cancelled remove: %s", showResult.Stderr) - assert.Assert(t, strings.Contains(showResult.Stdout, "1234"), "expected stored key (masked) in config output, got: %s", showResult.Stdout) + // Key should not have been removed — load config directly to avoid env-var override in Resolve. + cfg, err := config.Load() + assert.NilError(t, err, "config.Load failed after cancelled remove") + assert.Assert(t, strings.Contains(cfg.AnthropicAPIKey, "stored-key-1234"), + "expected stored key to be intact after cancelled remove, got: %q", cfg.AnthropicAPIKey) } // auth remove with both env var and config key. diff --git a/acceptance/config_test.go b/acceptance/config_test.go index d3024715..a89ecac7 100644 --- a/acceptance/config_test.go +++ b/acceptance/config_test.go @@ -56,25 +56,6 @@ func TestConfigShowMasksLastFourChars(t *testing.T) { "expected first chars of API key to be masked, got: %s", combined) } -// API key stored in config file (no env var) is resolved and shown -func TestConfigShowFromConfigFile(t *testing.T) { - env := testenv.NewTestEnv(t) - env.AnthropicKey = "" // no env var - - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - err := config.Save(config.UserConfig{AnthropicAPIKey: "sk-ant-stored-key-ZZZZ"}) - assert.NilError(t, err) - - result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - - combined := result.Stdout + result.Stderr - assert.Check(t, cmp.Contains(combined, "user config"), - "expected apiKey source to be 'user config'") - assert.Check(t, cmp.Contains(combined, "ZZZZ"), - "expected last 4 chars of stored key visible") -} - // config show must not display analyzeModel or promptModel func TestConfigShowNoModelConstants(t *testing.T) { env := testenv.NewTestEnv(t) @@ -201,24 +182,6 @@ func TestConfigShowCircleCITokenEnvPrecedenceOverFile(t *testing.T) { "expected env var source") } -func TestConfigShowCircleCITokenFromFile(t *testing.T) { - env := testenv.NewTestEnv(t) - env.CircleToken = "" // no env var - - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - err := config.Save(config.UserConfig{CircleCIToken: "stored-circle-FTOK"}) - assert.NilError(t, err) - - result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - - combined := result.Stdout + result.Stderr - assert.Check(t, cmp.Contains(combined, "FTOK"), - "expected file token last 4 chars") - assert.Check(t, cmp.Contains(combined, "user config"), - "expected config file source") -} - func TestConfigShowCircleTokenEnvPrecedenceOverCircleCIToken(t *testing.T) { env := testenv.NewTestEnv(t) env.CircleToken = "" // clear the default CIRCLE_TOKEN @@ -259,24 +222,6 @@ func TestConfigShowGitHubTokenEnvPrecedenceOverFile(t *testing.T) { "expected env var source") } -func TestConfigShowGitHubTokenFromFile(t *testing.T) { - env := testenv.NewTestEnv(t) - env.GithubToken = "" // no env var - - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - err := config.Save(config.UserConfig{GitHubToken: "stored-github-GHTK"}) - assert.NilError(t, err) - - result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - - combined := result.Stdout + result.Stderr - assert.Check(t, cmp.Contains(combined, "GHTK"), - "expected file token last 4 chars") - assert.Check(t, cmp.Contains(combined, "user config"), - "expected config file source") -} - func TestConfigShowModelFileOverDefault(t *testing.T) { env := testenv.NewTestEnv(t) diff --git a/acceptance/task_test.go b/acceptance/task_test.go index f2ad65ce..a0a5a706 100644 --- a/acceptance/task_test.go +++ b/acceptance/task_test.go @@ -539,34 +539,6 @@ func TestTaskRunStatsFieldWithBranchOverride(t *testing.T) { assert.Equal(t, stats["checkout_branch"], "feature/custom") } -func TestTaskRunCircleCITokenFallback(t *testing.T) { - // Verify CIRCLECI_TOKEN works when CIRCLE_TOKEN is empty - cci := fakes.NewFakeCircleCI() - srv := httptest.NewServer(cci) - defer srv.Close() - - workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo") - writeRunConfig(t, workDir) - - env := testenv.NewTestEnv(t) - env.CircleCIURL = srv.URL - env.CircleToken = "" // clear primary token - env.Extra["CIRCLECI_TOKEN"] = "fallback-circle-token" - - result := binary.RunCLI(t, []string{ - "task", "run", - "--definition", "dev", - "--prompt", "Test fallback", - }, env, workDir) - - assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) - - reqs := cci.Recorder.AllRequests() - runReqs := filterByPathPrefix(reqs, "/api/v2/agents/org/") - assert.Equal(t, len(runReqs), 1) - assert.Equal(t, runReqs[0].Header.Get("Circle-Token"), "fallback-circle-token") -} - func TestTaskRunMissingDefinitionFlag(t *testing.T) { // Cobra required flag --definition omitted workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo") diff --git a/docs/CLI.md b/docs/CLI.md index e814a29d..951a9281 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -8,8 +8,11 @@ Complete command reference for the `chunk` CLI. chunk ├── auth │ ├── set # Store credential (circleci | anthropic | github) +│ │ --insecure-storage # Save to config file instead of system keychain +│ │ --force, -f # Overwrite existing credentials without confirmation │ ├── status # Check authentication status (CircleCI, Anthropic, GitHub) │ └── remove # Remove stored credential (circleci | anthropic | github) +│ --force, -f # Skip confirmation prompt │ ├── build-prompt # Mine PR comments → analyze → generate prompt │ --org # GitHub org (auto-detected from git remote) @@ -127,8 +130,11 @@ chunk - Commands that require a CircleCI token (`task run`, `task config`, `sidecar *`, `validate --sidecar-id`) prompt for it inline at the point of need rather than failing with an error. -- `chunk auth set github` stores a GitHub token in the config file; previously - only the `GITHUB_TOKEN` environment variable was supported. +- `chunk auth set ` saves credentials to the system keychain by default + (macOS Keychain / Linux secret-service). Pass `--insecure-storage` to write to + the config file instead. Use `--force` / `-f` to overwrite without confirmation. +- `chunk auth remove ` removes credentials from both keychain and config + file. Use `--force` / `-f` to skip the confirmation prompt. ## Flag Conventions diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f86fa57e..e03c882b 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -63,7 +63,7 @@ Check status at any time: chunk auth status ``` -Credentials are stored in `~/.config/chunk/config.json` (respects `XDG_CONFIG_HOME`). You can also set them as environment variables: +Credentials are saved to the system keychain (macOS Keychain, Linux secret-service) by default. Add `--insecure-storage` to save to `~/.config/chunk/config.json` instead. You can also set them as environment variables: | Variable | Used by | |---|---| diff --git a/go.mod b/go.mod index 0a1beff2..19a6731c 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.8 github.com/sethvargo/go-envconfig v1.3.0 github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.51.0 golang.org/x/term v0.43.0 gotest.tools/v3 v3.5.2 @@ -85,6 +86,7 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect @@ -115,6 +117,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godoc-lint/godoc-lint v0.11.2 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/go.sum b/go.sum index 990d60a1..42741281 100644 --- a/go.sum +++ b/go.sum @@ -185,6 +185,8 @@ github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+f github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= @@ -281,6 +283,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -696,6 +700,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= diff --git a/internal/authprompt/authprompt.go b/internal/authprompt/authprompt.go index 973b2c90..50b8945e 100644 --- a/internal/authprompt/authprompt.go +++ b/internal/authprompt/authprompt.go @@ -11,6 +11,7 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/github" hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl" + "github.com/CircleCI-Public/chunk-cli/internal/keyring" "github.com/CircleCI-Public/chunk-cli/internal/version" ) @@ -114,43 +115,67 @@ func ResolveGitHubClient(rc config.ResolvedConfig, logStatus func(string)) (*git }) } -// SaveCircleCIToken persists a CircleCI token to the config file. -func SaveCircleCIToken(token string) error { +// SaveCircleCIToken persists a CircleCI token to the system keychain, or to +// the config file when insecureStorage is true or the keychain is unavailable. +// baseURL is used to scope the keychain entry to the CircleCI host. +// Returns true if saved to the keychain. +func SaveCircleCIToken(token, baseURL string, insecureStorage bool) (bool, error) { + if !insecureStorage { + if err := keyring.Set(keyring.CircleCITokenKey(baseURL), token); err == nil { + return true, nil + } + } cfg, err := config.Load() if err != nil { - return fmt.Errorf("load config: %w", err) + return false, fmt.Errorf("load config: %w", err) } cfg.CircleCIToken = token if err := config.Save(cfg); err != nil { - return fmt.Errorf("save token: %w", err) + return false, fmt.Errorf("save token: %w", err) } - return nil + return false, nil } -// SaveAnthropicKey persists an Anthropic API key to the config file. -func SaveAnthropicKey(key string) error { +// SaveAnthropicKey persists an Anthropic API key to the system keychain, or to +// the config file when insecureStorage is true or the keychain is unavailable. +// baseURL is used to scope the keychain entry to the Anthropic host. +// Returns true if saved to the keychain. +func SaveAnthropicKey(key, baseURL string, insecureStorage bool) (bool, error) { + if !insecureStorage { + if err := keyring.Set(keyring.AnthropicKeyKey(baseURL), key); err == nil { + return true, nil + } + } cfg, err := config.Load() if err != nil { - return fmt.Errorf("load config: %w", err) + return false, fmt.Errorf("load config: %w", err) } cfg.AnthropicAPIKey = key if err := config.Save(cfg); err != nil { - return fmt.Errorf("save API key: %w", err) + return false, fmt.Errorf("save API key: %w", err) } - return nil + return false, nil } -// SaveGitHubToken persists a GitHub token to the config file. -func SaveGitHubToken(token string) error { +// SaveGitHubToken persists a GitHub token to the system keychain, or to the +// config file when insecureStorage is true or the keychain is unavailable. +// apiURL is used to scope the keychain entry to the GitHub host. +// Returns true if saved to the keychain. +func SaveGitHubToken(token, apiURL string, insecureStorage bool) (bool, error) { + if !insecureStorage { + if err := keyring.Set(keyring.GitHubTokenKey(apiURL), token); err == nil { + return true, nil + } + } cfg, err := config.Load() if err != nil { - return fmt.Errorf("load config: %w", err) + return false, fmt.Errorf("load config: %w", err) } cfg.GitHubToken = token if err := config.Save(cfg); err != nil { - return fmt.Errorf("save token: %w", err) + return false, fmt.Errorf("save token: %w", err) } - return nil + return false, nil } // ValidateGitHubToken calls GET /user to confirm the token is accepted. diff --git a/internal/authprompt/authprompt_test.go b/internal/authprompt/authprompt_test.go index 46e6a421..4d71675b 100644 --- a/internal/authprompt/authprompt_test.go +++ b/internal/authprompt/authprompt_test.go @@ -21,6 +21,10 @@ func isolateConfig(t *testing.T) { home := t.TempDir() t.Setenv(config.EnvHome, home) t.Setenv(config.EnvXDGConfigHome, filepath.Join(home, ".config")) + t.Setenv(config.EnvCircleToken, "dummy-circle-token") + t.Setenv(config.EnvCircleCIToken, "dummy-circleci-token") + t.Setenv(config.EnvAnthropicAPIKey, "dummy-anthropic-key") + t.Setenv(config.EnvGitHubToken, "dummy-github-token") } func randToken(prefix string) string { @@ -64,29 +68,7 @@ func TestResolveCircleCIClient_TokenInEnv(t *testing.T) { defer srv.Close() t.Setenv(config.EnvCircleToken, randToken("cci-")) - t.Setenv(config.EnvCircleCIBaseURL, srv.URL) - - rc, _ := config.Resolve("", "") - client, err := authprompt.ResolveCircleCIClient(rc) - assert.NilError(t, err) - assert.Assert(t, client != nil) -} - -func TestResolveCircleCIClient_TokenInConfig(t *testing.T) { - isolateConfig(t) - - cci := fakes.NewFakeCircleCI() - srv := httptest.NewServer(cci) - defer srv.Close() - - t.Setenv(config.EnvCircleCIBaseURL, srv.URL) - t.Setenv(config.EnvCircleToken, "") - t.Setenv(config.EnvCircleCIToken, "") - - cfg, err := config.Load() - assert.NilError(t, err) - cfg.CircleCIToken = randToken("cci-") - assert.NilError(t, config.Save(cfg)) + t.Setenv(config.EnvCircleHost, srv.URL) rc, _ := config.Resolve("", "") client, err := authprompt.ResolveCircleCIClient(rc) @@ -120,27 +102,6 @@ func TestResolveAnthropicClient_KeyInEnv(t *testing.T) { assert.Assert(t, client != nil) } -func TestResolveAnthropicClient_KeyInConfig(t *testing.T) { - isolateConfig(t) - - ant := fakes.NewFakeAnthropic("ok") - srv := httptest.NewServer(ant) - defer srv.Close() - - t.Setenv(config.EnvAnthropicBaseURL, srv.URL) - t.Setenv(config.EnvAnthropicAPIKey, "") - - cfg, err := config.Load() - assert.NilError(t, err) - cfg.AnthropicAPIKey = randToken("sk-ant-") - assert.NilError(t, config.Save(cfg)) - - rc, _ := config.Resolve("", "") - client, err := authprompt.ResolveAnthropicClient(rc) - assert.NilError(t, err) - assert.Assert(t, client != nil) -} - func TestResolveAnthropicClient_NeedsAuth(t *testing.T) { isolateConfig(t) t.Setenv(config.EnvAnthropicAPIKey, "") @@ -166,27 +127,6 @@ func TestResolveGitHubClient_TokenInEnv(t *testing.T) { assert.Assert(t, client != nil) } -func TestResolveGitHubClient_TokenInConfig(t *testing.T) { - isolateConfig(t) - - gh := fakes.NewFakeGitHub() - srv := httptest.NewServer(gh) - defer srv.Close() - - t.Setenv(config.EnvGitHubAPIURL, srv.URL) - t.Setenv(config.EnvGitHubToken, "") - - cfg, err := config.Load() - assert.NilError(t, err) - cfg.GitHubToken = randToken("ghp_") - assert.NilError(t, config.Save(cfg)) - - rc, _ := config.Resolve("", "") - client, err := authprompt.ResolveGitHubClient(rc, nil) - assert.NilError(t, err) - assert.Assert(t, client != nil) -} - func TestResolveGitHubClient_NeedsAuth(t *testing.T) { isolateConfig(t) t.Setenv(config.EnvGitHubToken, "") @@ -200,7 +140,7 @@ func TestSaveCircleCIToken(t *testing.T) { isolateConfig(t) token := randToken("cci-") - err := authprompt.SaveCircleCIToken(token) + _, err := authprompt.SaveCircleCIToken(token, "", true) assert.NilError(t, err) cfg, err := config.Load() @@ -212,7 +152,7 @@ func TestSaveAnthropicKey(t *testing.T) { isolateConfig(t) key := randToken("sk-ant-") - err := authprompt.SaveAnthropicKey(key) + _, err := authprompt.SaveAnthropicKey(key, "", true) assert.NilError(t, err) cfg, err := config.Load() @@ -224,7 +164,7 @@ func TestSaveGitHubToken(t *testing.T) { isolateConfig(t) token := randToken("ghp_") - err := authprompt.SaveGitHubToken(token) + _, err := authprompt.SaveGitHubToken(token, "", true) assert.NilError(t, err) cfg, err := config.Load() diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index bf3de75c..19d1509f 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -35,6 +35,7 @@ func newAuthCmd() *cobra.Command { } func newAuthSetCmd() *cobra.Command { + var insecureStorage bool var force bool cmd := &cobra.Command{ Use: "set ", @@ -47,14 +48,11 @@ func newAuthSetCmd() *cobra.Command { io := iostream.FromCmd(cmd) switch provider { case providerCircleCI: - envSet := strings.HasPrefix(rc.CircleCITokenSource, "Environment") - return authSetCircleCI(cmd.Context(), io, rc.CircleCIBaseURL, envSet, force) + return authSetCircleCI(cmd.Context(), io, rc, insecureStorage, force) case providerAnthropic: - envSet := strings.HasPrefix(rc.AnthropicAPIKeySource, "Environment") - return authSetAnthropic(cmd.Context(), io, rc.AnthropicBaseURL, envSet, force) + return authSetAnthropic(cmd.Context(), io, rc, insecureStorage, force) case providerGitHub: - envSet := strings.HasPrefix(rc.GitHubTokenSource, "Environment") - return authSetGitHub(cmd.Context(), io, rc.GitHubAPIURL, envSet, force) + return authSetGitHub(cmd.Context(), io, rc, insecureStorage, force) default: return &userError{ msg: fmt.Sprintf("Unknown provider %q.", provider), @@ -64,16 +62,18 @@ func newAuthSetCmd() *cobra.Command { } }, } + cmd.Flags().BoolVar(&insecureStorage, "insecure-storage", false, "Save credentials to config file instead of system keychain") cmd.Flags().BoolVarP(&force, "force", "f", false, "Overwrite existing credentials without confirmation") return cmd } -func authSetCircleCI(ctx context.Context, io iostream.Streams, baseURL string, envSet, force bool) error { +func authSetCircleCI(ctx context.Context, io iostream.Streams, rc config.ResolvedConfig, insecureStorage, force bool) error { + envSet := strings.HasPrefix(rc.CircleCITokenSource, "Environment") io.Println("") io.Println(ui.Bold("Chunk CLI - CircleCI Token Setup")) io.Println("") io.Println("Create a CircleCI token at https://app.circleci.com/settings/user/tokens") - printSaveHint(io, "Token") + printSaveHint(io, "Token", insecureStorage) io.Println("") if envSet { @@ -82,12 +82,8 @@ func authSetCircleCI(ctx context.Context, io iostream.Streams, baseURL string, e io.Println("") } - cfg, err := config.Load() - if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - if cfg.CircleCIToken != "" { - io.Printf("A CircleCI token is already stored in config.\n") + if rc.CircleCIToken != "" && !envSet { + io.Printf("A CircleCI token is already stored.\n") if !force { if nonInteractive() { return errNoForce("replace CircleCI token") @@ -124,15 +120,16 @@ func authSetCircleCI(ctx context.Context, io iostream.Streams, baseURL string, e } } - return saveCircleCIToken(ctx, token, io, baseURL) + return saveCircleCIToken(ctx, token, io, rc.CircleCIBaseURL, insecureStorage) } -func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, envSet, force bool) error { +func authSetAnthropic(ctx context.Context, io iostream.Streams, rc config.ResolvedConfig, insecureStorage, force bool) error { + envSet := strings.HasPrefix(rc.AnthropicAPIKeySource, "Environment") io.Println("") io.Println(ui.Bold("Chunk CLI - Anthropic API Key Setup")) io.Println("") io.Println("Enter your Anthropic API key (starts with sk-ant-).") - printSaveHint(io, "Key") + printSaveHint(io, "Key", insecureStorage) io.Println("") if envSet { io.Println(ui.Warning("An Anthropic API key is set in environment variables (" + config.EnvAnthropicAPIKey + ").")) @@ -140,12 +137,8 @@ func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, io.Println("") } - cfg, err := config.Load() - if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - if cfg.AnthropicAPIKey != "" { - io.Printf("An Anthropic API key is already stored in config.\n") + if rc.AnthropicAPIKey != "" && !envSet { + io.Printf("An Anthropic API key is already stored.\n") if !force { if nonInteractive() { return errNoForce("replace Anthropic API key") @@ -192,7 +185,7 @@ func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, } io.ErrPrintln(ui.Dim("Validating API key...")) - if err := authprompt.ValidateAPIKey(ctx, key, baseURL); err != nil { + if err := authprompt.ValidateAPIKey(ctx, key, rc.AnthropicBaseURL); err != nil { return &userError{ msg: "API key validation failed.", suggestion: "Check that your key is correct and has not been revoked.", @@ -200,22 +193,18 @@ func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, } } - cfg, err = config.Load() + savedToKeychain, err := authprompt.SaveAnthropicKey(key, rc.AnthropicBaseURL, insecureStorage) if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - cfg.AnthropicAPIKey = key - if err := config.Save(cfg); err != nil { return &userError{msg: "Could not save credentials.", suggestion: configFilePermHint, err: err} } io.Println("") - printSaved(io, "Anthropic API key") + printSaved(io, "Anthropic API key", savedToKeychain) io.Println(ui.Dim("You can now run code reviews with: chunk build-prompt")) return nil } -func saveCircleCIToken(ctx context.Context, token string, streams iostream.Streams, circleCIBaseURL string) error { +func saveCircleCIToken(ctx context.Context, token string, streams iostream.Streams, circleCIBaseURL string, insecureStorage bool) error { streams.ErrPrintln(ui.Dim("Validating CircleCI token...")) if err := authprompt.ValidateCircleCIToken(ctx, token, circleCIBaseURL); err != nil { return &userError{ @@ -225,12 +214,8 @@ func saveCircleCIToken(ctx context.Context, token string, streams iostream.Strea } } - cfg, err := config.Load() + savedToKeychain, err := authprompt.SaveCircleCIToken(token, circleCIBaseURL, insecureStorage) if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - cfg.CircleCIToken = token - if err := config.Save(cfg); err != nil { return &userError{ msg: "Failed to save CircleCI token.", suggestion: "Check that your config file is writable.", @@ -239,7 +224,7 @@ func saveCircleCIToken(ctx context.Context, token string, streams iostream.Strea } streams.ErrPrintln("") - printSaved(streams, "CircleCI token") + printSaved(streams, "CircleCI token", savedToKeychain) return nil } @@ -481,12 +466,13 @@ func authRemoveAnthropic(io iostream.Streams, envSet, force bool) error { return nil } -func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, envSet, force bool) error { +func authSetGitHub(ctx context.Context, io iostream.Streams, rc config.ResolvedConfig, insecureStorage, force bool) error { + envSet := strings.HasPrefix(rc.GitHubTokenSource, "Environment") io.Println("") io.Println(ui.Bold("Chunk CLI - GitHub Token Setup")) io.Println("") io.Println("Create a token at https://github.com/settings/tokens") - printSaveHint(io, "Token") + printSaveHint(io, "Token", insecureStorage) io.Println("") if envSet { @@ -495,12 +481,8 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, env io.Println("") } - cfg, err := config.Load() - if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - if cfg.GitHubToken != "" { - io.Printf("A GitHub token is already stored in config.\n") + if rc.GitHubToken != "" && !envSet { + io.Printf("A GitHub token is already stored.\n") if !force { if nonInteractive() { return errNoForce("replace GitHub token") @@ -538,7 +520,7 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, env } io.ErrPrintln(ui.Dim("Validating GitHub token...")) - if err := authprompt.ValidateGitHubToken(ctx, token, baseURL); err != nil { + if err := authprompt.ValidateGitHubToken(ctx, token, rc.GitHubAPIURL); err != nil { return &userError{ msg: "GitHub token validation failed.", suggestion: "Check that your token is correct and has not been revoked.", @@ -546,17 +528,13 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, env } } - cfg, err = config.Load() + savedToKeychain, err := authprompt.SaveGitHubToken(token, rc.GitHubAPIURL, insecureStorage) if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - cfg.GitHubToken = token - if err := config.Save(cfg); err != nil { return &userError{msg: "Could not save credentials.", suggestion: configFilePermHint, err: err} } io.Println("") - printSaved(io, "GitHub token") + printSaved(io, "GitHub token", savedToKeychain) return nil } diff --git a/internal/cmd/authhelper.go b/internal/cmd/authhelper.go index 4a9a2c45..c8e25964 100644 --- a/internal/cmd/authhelper.go +++ b/internal/cmd/authhelper.go @@ -24,13 +24,21 @@ const ( suggestionGitHubAuth = "Set " + config.EnvGitHubToken + " or run 'chunk auth set github'." ) -func printSaveHint(streams iostream.Streams, label string) { - if cfgPath, err := config.Path(); err == nil { - streams.ErrPrintln(ui.Dim(fmt.Sprintf("%s will be saved to user config (%s, mode 0600)", label, cfgPath))) +func printSaveHint(streams iostream.Streams, label string, insecureStorage bool) { + if insecureStorage { + if cfgPath, err := config.Path(); err == nil { + streams.ErrPrintln(ui.Dim(fmt.Sprintf("%s will be saved to user config (%s, mode 0600)", label, cfgPath))) + } + } else { + streams.ErrPrintln(ui.Dim(label + " will be saved to system keychain")) } } -func printSaved(streams iostream.Streams, label string) { +func printSaved(streams iostream.Streams, label string, savedToKeychain bool) { + if savedToKeychain { + streams.ErrPrintln(ui.Success(label + " saved to system keychain")) + return + } msg := label + " saved" if cfgPath, err := config.Path(); err == nil { msg = fmt.Sprintf("%s saved to user config (%s)", label, cfgPath) @@ -51,7 +59,7 @@ func ensureCircleCIClient(ctx context.Context, streams iostream.Streams, prompte streams.ErrPrintln("") streams.ErrPrintln(ui.Bold("CircleCI token required")) streams.ErrPrintln("Create a token at https://app.circleci.com/settings/user/tokens") - printSaveHint(streams, "Token") + printSaveHint(streams, "Token", false) streams.ErrPrintln("") token, err := prompter("CircleCI Token") @@ -82,10 +90,11 @@ func ensureCircleCIClient(ctx context.Context, streams iostream.Streams, prompte return nil, fmt.Errorf("could not validate CircleCI token: %w", err) } - if err := authprompt.SaveCircleCIToken(token); err != nil { + savedToKeychain, err := authprompt.SaveCircleCIToken(token, rc.CircleCIBaseURL, false) + if err != nil { return nil, err } - printSaved(streams, "CircleCI token") + printSaved(streams, "CircleCI token", savedToKeychain) return circleci.NewClient(circleci.Config{ Token: token, BaseURL: rc.CircleCIBaseURL, @@ -105,7 +114,7 @@ func ensureAnthropicClient(ctx context.Context, streams iostream.Streams, prompt streams.ErrPrintln("") streams.ErrPrintln(ui.Bold("Anthropic API key required")) streams.ErrPrintln("Get a key at https://console.anthropic.com/") - printSaveHint(streams, "Key") + printSaveHint(streams, "Key", false) streams.ErrPrintln("") key, err := prompter("API Key") @@ -144,10 +153,11 @@ func ensureAnthropicClient(ctx context.Context, streams iostream.Streams, prompt return nil, fmt.Errorf("could not validate Anthropic API key: %w", err) } - if err := authprompt.SaveAnthropicKey(key); err != nil { + savedToKeychain, err := authprompt.SaveAnthropicKey(key, rc.AnthropicBaseURL, false) + if err != nil { return nil, err } - printSaved(streams, "Anthropic API key") + printSaved(streams, "Anthropic API key", savedToKeychain) return anthropic.New(anthropic.Config{ APIKey: key, BaseURL: rc.AnthropicBaseURL, @@ -168,7 +178,7 @@ func ensureGitHubClient(ctx context.Context, streams iostream.Streams, prompter streams.ErrPrintln("") streams.ErrPrintln(ui.Bold("GitHub token required")) streams.ErrPrintln("Create a token at https://github.com/settings/tokens") - printSaveHint(streams, "Token") + printSaveHint(streams, "Token", false) streams.ErrPrintln("") token, err := prompter("GitHub Token") @@ -199,10 +209,11 @@ func ensureGitHubClient(ctx context.Context, streams iostream.Streams, prompter return nil, fmt.Errorf("could not validate GitHub token: %w", err) } - if err := authprompt.SaveGitHubToken(token); err != nil { + savedToKeychain, err := authprompt.SaveGitHubToken(token, rc.GitHubAPIURL, false) + if err != nil { return nil, err } - printSaved(streams, "GitHub token") + printSaved(streams, "GitHub token", savedToKeychain) return github.New(github.Config{ Token: token, BaseURL: rc.GitHubAPIURL, diff --git a/internal/config/config.go b/internal/config/config.go index 418019d3..ab88fd1e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,8 @@ import ( "strings" "github.com/sethvargo/go-envconfig" + + "github.com/CircleCI-Public/chunk-cli/internal/keyring" ) // Model constants define the Claude models used for different operations. @@ -23,6 +25,8 @@ const ( // SourceConfigFile is the source label used when a value comes from the user config file. SourceConfigFile = "Config file (user config)" + // SourceKeychain is the source label used when a value comes from the system keychain. + SourceKeychain = "System keychain" ) // Chunk-specific environment variable names. @@ -31,7 +35,7 @@ const ( const ( EnvCircleToken = "CIRCLE_TOKEN" EnvCircleCIToken = "CIRCLECI_TOKEN" - EnvCircleCIBaseURL = "CIRCLECI_BASE_URL" + EnvCircleHost = "CIRCLE_HOST" EnvAnthropicAPIKey = "ANTHROPIC_API_KEY" EnvAnthropicBaseURL = "ANTHROPIC_BASE_URL" EnvGitHubToken = "GITHUB_TOKEN" @@ -57,7 +61,7 @@ const ( type EnvVars struct { CircleToken string `env:"CIRCLE_TOKEN"` CircleCIToken string `env:"CIRCLECI_TOKEN"` - CircleCIBaseURL string `env:"CIRCLECI_BASE_URL,default=https://circleci.com"` + CircleHost string `env:"CIRCLE_HOST,default=https://circleci.com"` AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"` AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL,default=https://api.anthropic.com"` GitHubToken string `env:"GITHUB_TOKEN"` @@ -156,73 +160,116 @@ func Save(cfg UserConfig) error { return os.WriteFile(p, data, filePermission) } -// Clear removes a stored config value by key. +// Clear removes a stored config value by key (both keychain and config file). func Clear(key string) error { cfg, err := Load() if err != nil { return err } + env, _ := LoadEnv(context.Background()) + var keychainKey string switch key { case "anthropicAPIKey": cfg.AnthropicAPIKey = "" + keychainKey = keyring.AnthropicKeyKey(env.AnthropicBaseURL) case "circleCIToken": cfg.CircleCIToken = "" + keychainKey = keyring.CircleCITokenKey(env.CircleHost) case "gitHubToken": cfg.GitHubToken = "" + keychainKey = keyring.GitHubTokenKey(env.GitHubAPIURL) default: return fmt.Errorf("unknown config key: %s", key) } + _ = keyring.Delete(keychainKey) // best-effort return Save(cfg) } -// Resolve computes the final config from flags, env, and file. -// Priority for API key: flag > env > config file > (none). -// Priority for model: flag > env > config file > default. -func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { - cfg, err := Load() - - env, envErr := LoadEnv(context.Background()) - if envErr != nil { - return ResolvedConfig{}, envErr - } - - rc := ResolvedConfig{ - AnalyzeModel: AnalyzeModel, - PromptModel: PromptModel, - } - - switch { - case env.CircleToken != "": - rc.CircleCIToken = env.CircleToken +func resolveCircleCIToken(rc *ResolvedConfig, cfg UserConfig, env EnvVars) { + if val, ok := os.LookupEnv(EnvCircleToken); ok { + rc.CircleCIToken = val rc.CircleCITokenSource = "Environment variable (" + EnvCircleToken + ")" - case env.CircleCIToken != "": - rc.CircleCIToken = env.CircleCIToken + return + } + if val, ok := os.LookupEnv(EnvCircleCIToken); ok { + rc.CircleCIToken = val rc.CircleCITokenSource = "Environment variable (" + EnvCircleCIToken + ")" - case cfg.CircleCIToken != "": + return + } + // No env var present: check keychain then file. + if val, _ := keyring.Get(keyring.CircleCITokenKey(env.CircleHost)); val != "" { + rc.CircleCIToken = val + rc.CircleCITokenSource = SourceKeychain + return + } + if cfg.CircleCIToken != "" { rc.CircleCIToken = cfg.CircleCIToken rc.CircleCITokenSource = SourceConfigFile } +} - switch { - case flagAPIKey != "": +func resolveAnthropicKey(rc *ResolvedConfig, cfg UserConfig, env EnvVars, flagAPIKey string) { + if flagAPIKey != "" { rc.AnthropicAPIKey = flagAPIKey rc.AnthropicAPIKeySource = "Flag" - case env.AnthropicAPIKey != "": - rc.AnthropicAPIKey = env.AnthropicAPIKey + return + } + if val, ok := os.LookupEnv(EnvAnthropicAPIKey); ok { + rc.AnthropicAPIKey = val rc.AnthropicAPIKeySource = "Environment variable" - case cfg.AnthropicAPIKey != "": + return + } + if val, _ := keyring.Get(keyring.AnthropicKeyKey(env.AnthropicBaseURL)); val != "" { + rc.AnthropicAPIKey = val + rc.AnthropicAPIKeySource = SourceKeychain + return + } + if cfg.AnthropicAPIKey != "" { rc.AnthropicAPIKey = cfg.AnthropicAPIKey rc.AnthropicAPIKeySource = SourceConfigFile } +} - switch { - case env.GitHubToken != "": - rc.GitHubToken = env.GitHubToken +func resolveGitHubToken(rc *ResolvedConfig, cfg UserConfig, env EnvVars) { + if val, ok := os.LookupEnv(EnvGitHubToken); ok { + rc.GitHubToken = val rc.GitHubTokenSource = "Environment variable (" + EnvGitHubToken + ")" - case cfg.GitHubToken != "": + return + } + if val, _ := keyring.Get(keyring.GitHubTokenKey(env.GitHubAPIURL)); val != "" { + rc.GitHubToken = val + rc.GitHubTokenSource = SourceKeychain + return + } + if cfg.GitHubToken != "" { rc.GitHubToken = cfg.GitHubToken rc.GitHubTokenSource = SourceConfigFile } +} + +// Resolve computes the final config from flags, env, and file. +// Priority for API key: flag > env > config file > (none). +// Priority for model: flag > env > config file > default. +func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { + cfg, err := Load() + + env, envErr := LoadEnv(context.Background()) + if envErr != nil { + return ResolvedConfig{}, envErr + } + + rc := ResolvedConfig{ + AnalyzeModel: AnalyzeModel, + PromptModel: PromptModel, + } + + // For each credential: if the env var is explicitly present in the process + // environment, it takes priority. A non-empty value is used directly; an + // empty value suppresses the keychain check so tests that clear env vars + // still fall through to the config file without touching the keychain. + resolveCircleCIToken(&rc, cfg, env) + resolveAnthropicKey(&rc, cfg, env, flagAPIKey) + resolveGitHubToken(&rc, cfg, env) switch { case flagModel != "": @@ -239,7 +286,7 @@ func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { rc.ModelSource = "Default" } - rc.CircleCIBaseURL = env.CircleCIBaseURL + rc.CircleCIBaseURL = env.CircleHost rc.AnthropicBaseURL = env.AnthropicBaseURL rc.GitHubAPIURL = env.GitHubAPIURL diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8bf6d95e..4abc48d6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,6 +14,10 @@ func setupTempConfig(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv(EnvXDGConfigHome, dir) + t.Setenv(EnvCircleToken, "dummy-circle-token") + t.Setenv(EnvCircleCIToken, "dummy-circleci-token") + t.Setenv(EnvAnthropicAPIKey, "dummy-anthropic-key") + t.Setenv(EnvGitHubToken, "dummy-github-token") return dir } diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 00000000..1a5db700 --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,109 @@ +package keyring + +import ( + "context" + "errors" + "fmt" + "time" + + zkeyring "github.com/zalando/go-keyring" +) + +const ( + service = "com.circleci.cli" + timeout = 3 * time.Second +) + +// CircleCITokenKey returns the keychain account name for a CircleCI token +// scoped to the given base URL. +func CircleCITokenKey(baseURL string) string { + if baseURL == "" { + baseURL = "https://circleci.com" + } + return service + ":" + baseURL +} + +// GitHubTokenKey returns the keychain account name for a GitHub token scoped +// to the given API URL. +func GitHubTokenKey(apiURL string) string { + if apiURL == "" { + apiURL = "https://api.github.com" + } + return service + ":" + apiURL +} + +// AnthropicKeyKey returns the keychain account name for an Anthropic API key +// scoped to the given base URL. +func AnthropicKeyKey(baseURL string) string { + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + return service + ":" + baseURL +} + +// Get retrieves a credential from the system keychain. +// Returns ("", nil) if the key is not found. +func Get(key string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + type result struct { + val string + err error + } + done := make(chan result, 1) + go func() { + val, err := zkeyring.Get(service, key) + done <- result{val, err} + }() + + select { + case <-ctx.Done(): + return "", fmt.Errorf("keychain timeout") + case r := <-done: + if errors.Is(r.err, zkeyring.ErrNotFound) { + return "", nil + } + return r.val, r.err + } +} + +// Set stores a credential in the system keychain. +func Set(key, value string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- zkeyring.Set(service, key, value) + }() + + select { + case <-ctx.Done(): + return fmt.Errorf("keychain timeout") + case err := <-done: + return err + } +} + +// Delete removes a credential from the system keychain. +// Returns nil if the key was not found. +func Delete(key string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- zkeyring.Delete(service, key) + }() + + select { + case <-ctx.Done(): + return fmt.Errorf("keychain timeout") + case err := <-done: + if errors.Is(err, zkeyring.ErrNotFound) { + return nil + } + return err + } +} diff --git a/internal/keyring/keyring_test.go b/internal/keyring/keyring_test.go new file mode 100644 index 00000000..2612aae6 --- /dev/null +++ b/internal/keyring/keyring_test.go @@ -0,0 +1,51 @@ +package keyring_test + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/keyring" +) + +func TestCircleCITokenKey(t *testing.T) { + tests := []struct { + baseURL string + want string + }{ + {"", "com.circleci.cli:https://circleci.com"}, + {"https://circleci.com", "com.circleci.cli:https://circleci.com"}, + {"https://circleci.mycompany.com", "com.circleci.cli:https://circleci.mycompany.com"}, + } + for _, tt := range tests { + assert.Equal(t, keyring.CircleCITokenKey(tt.baseURL), tt.want) + } +} + +func TestGitHubTokenKey(t *testing.T) { + tests := []struct { + apiURL string + want string + }{ + {"", "com.circleci.cli:https://api.github.com"}, + {"https://api.github.com", "com.circleci.cli:https://api.github.com"}, + {"https://github.mycompany.com/api/v3", "com.circleci.cli:https://github.mycompany.com/api/v3"}, + } + for _, tt := range tests { + assert.Equal(t, keyring.GitHubTokenKey(tt.apiURL), tt.want) + } +} + +func TestAnthropicKeyKey(t *testing.T) { + tests := []struct { + baseURL string + want string + }{ + {"", "com.circleci.cli:https://api.anthropic.com"}, + {"https://api.anthropic.com", "com.circleci.cli:https://api.anthropic.com"}, + {"https://anthropic-proxy.mycompany.com", "com.circleci.cli:https://anthropic-proxy.mycompany.com"}, + } + for _, tt := range tests { + assert.Equal(t, keyring.AnthropicKeyKey(tt.baseURL), tt.want) + } +} diff --git a/internal/testing/env/env.go b/internal/testing/env/env.go index 2797b975..aec13877 100644 --- a/internal/testing/env/env.go +++ b/internal/testing/env/env.go @@ -47,15 +47,11 @@ func (e *TestEnv) Environ() []string { "TERM=dumb", } - if e.GithubToken != "" { - env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", e.GithubToken)) - } - if e.AnthropicKey != "" { - env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", e.AnthropicKey)) - } - if e.CircleToken != "" { - env = append(env, fmt.Sprintf("CIRCLE_TOKEN=%s", e.CircleToken)) - } + // Always set credential env vars so that Resolve() hits the env-var path + // and never accesses the system keychain from test subprocesses. + env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", e.GithubToken)) + env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", e.AnthropicKey)) + env = append(env, fmt.Sprintf("CIRCLE_TOKEN=%s", e.CircleToken)) if e.GithubURL != "" { env = append(env, fmt.Sprintf("GITHUB_API_URL=%s", e.GithubURL)) } @@ -63,7 +59,7 @@ func (e *TestEnv) Environ() []string { env = append(env, fmt.Sprintf("ANTHROPIC_BASE_URL=%s", e.AnthropicURL)) } if e.CircleCIURL != "" { - env = append(env, fmt.Sprintf("CIRCLECI_BASE_URL=%s", e.CircleCIURL)) + env = append(env, fmt.Sprintf("CIRCLE_HOST=%s", e.CircleCIURL)) } for k, v := range e.Extra {