From 385d0fd40602905ef3cf8cc2580fd084488f8a4d Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 9 Apr 2026 13:26:33 +0100 Subject: [PATCH 1/3] fix(cli): recover from invalid stored key and clarify auth errors - Map API 401s to a friendly message in Execute(); gateway MCP prints it to stderr only. - Log 401 responses at debug in the HTTP client to reduce noisy errors for whoami and agents. - Add hookdeck.IsUnauthorizedError for consistent detection. - login: if /cli-auth/validate returns 401, clear the stale in-memory key and continue into the browser/device flow. - Reset the cached API client after a successful browser login so the same process uses the new key. - Tests: hookdeck client + login unit tests; basic acceptance (mock login after 401, ci invalid key fast-fail without browser phrases). Made-with: Cursor --- pkg/cmd/root.go | 11 +- pkg/config/apiclient.go | 10 +- pkg/hookdeck/client.go | 25 +++ pkg/hookdeck/client_test.go | 14 ++ pkg/login/client_login.go | 39 +++-- pkg/login/client_login_test.go | 121 +++++++++++++ test/acceptance/README.md | 2 + test/acceptance/login_auth_acceptance_test.go | 163 ++++++++++++++++++ 8 files changed, 367 insertions(+), 18 deletions(-) create mode 100644 pkg/login/client_login_test.go create mode 100644 test/acceptance/login_auth_acceptance_test.go diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index a15ad2e2..a3a44e6e 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -121,7 +121,16 @@ func Execute() { } default: - if gatewayMCP { + if hookdeck.IsUnauthorizedError(err) { + msg := "Authentication failed: your API key is invalid or expired.\n\n" + + "Sign in again: run `hookdeck login` (browser sign-in), or `hookdeck login -i` / `hookdeck --api-key login`.\n\n" + + "MCP: use hookdeck_login with reauth: true." + if gatewayMCP { + fmt.Fprintln(os.Stderr, msg) + } else { + fmt.Println(msg) + } + } else if gatewayMCP { fmt.Fprintln(os.Stderr, err) } else { fmt.Println(err) diff --git a/pkg/config/apiclient.go b/pkg/config/apiclient.go index f1dc1285..7f0b03a2 100644 --- a/pkg/config/apiclient.go +++ b/pkg/config/apiclient.go @@ -10,11 +10,17 @@ import ( var apiClient *hookdeck.Client var apiClientOnce sync.Once +// ResetAPIClient clears the cached API client singleton. The next GetAPIClient +// call builds a fresh client from the current config (used after login updates credentials). +func ResetAPIClient() { + apiClient = nil + apiClientOnce = sync.Once{} +} + // ResetAPIClientForTesting resets the global API client singleton so that // tests can start with a fresh instance. Must only be called from tests. func ResetAPIClientForTesting() { - apiClient = nil - apiClientOnce = sync.Once{} + ResetAPIClient() } // GetAPIClient returns the internal API client instance diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 4826a34a..e0fdfafa 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os" + "strings" "time" "github.com/hookdeck/hookdeck-cli/pkg/useragent" @@ -126,6 +127,22 @@ func IsNotFoundError(err error) bool { return errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusGone) } +// IsUnauthorizedError reports whether err is an HTTP 401 from the Hookdeck API +// (invalid or rejected credentials). Non-JSON 401 bodies still become *APIError +// with StatusCode 401; a plain error string containing "status code: 401" is +// treated as unauthorized for wrapped failures. +func IsUnauthorizedError(err error) bool { + if err == nil { + return false + } + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "status code: 401") +} + // PerformRequest sends a request to Hookdeck and returns the response. func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.Response, error) { if req.Header == nil { @@ -208,6 +225,14 @@ func (c *Client) PerformRequest(ctx context.Context, req *http.Request) (*http.R "url": req.URL.String(), "status": resp.StatusCode, }).Debug("Rate limited") + } else if resp.StatusCode == http.StatusUnauthorized { + // Invalid or expired keys are common; avoid ERROR-level noise (e.g. whoami, agents). + log.WithFields(log.Fields{ + "prefix": "client.Client.PerformRequest", + "method": req.Method, + "url": req.URL.String(), + "status": resp.StatusCode, + }).Debug("Unauthorized response") } else { log.WithFields(log.Fields{ "prefix": "client.Client.PerformRequest 2", diff --git a/pkg/hookdeck/client_test.go b/pkg/hookdeck/client_test.go index efcf29dd..e677167f 100644 --- a/pkg/hookdeck/client_test.go +++ b/pkg/hookdeck/client_test.go @@ -2,6 +2,7 @@ package hookdeck import ( "context" + "fmt" "io" "io/ioutil" "net/http" @@ -18,6 +19,19 @@ func TestIsNotFoundError_410Gone(t *testing.T) { require.False(t, IsNotFoundError(&APIError{StatusCode: http.StatusInternalServerError, Message: "err"})) } +func TestIsUnauthorizedError(t *testing.T) { + require.True(t, IsUnauthorizedError(&APIError{StatusCode: http.StatusUnauthorized, Message: "Unauthorized"})) + require.True(t, IsUnauthorizedError(&APIError{ + StatusCode: http.StatusUnauthorized, + Message: "unexpected http status code: 401, raw response body: Unauthorized", + })) + require.True(t, IsUnauthorizedError(fmt.Errorf("wrapped: %w", &APIError{StatusCode: http.StatusUnauthorized}))) + require.True(t, IsUnauthorizedError(fmt.Errorf("unexpected http status code: 401, raw response body: Unauthorized"))) + require.False(t, IsUnauthorizedError(&APIError{StatusCode: http.StatusForbidden, Message: "nope"})) + require.False(t, IsUnauthorizedError(nil)) + require.False(t, IsUnauthorizedError(fmt.Errorf("network down"))) +} + func TestPerformRequest_ParamsEncoding_Delete(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/delete", r.URL.Path) diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 678cb0b5..e134d86a 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -33,22 +33,29 @@ func Login(config *configpkg.Config, input io.Reader) error { s = ansi.StartNewSpinner("Verifying credentials...", os.Stdout) response, err := config.GetAPIClient().ValidateAPIKey() if err != nil { - return err + ansi.StopSpinner(s, "", os.Stdout) + if !hookdeck.IsUnauthorizedError(err) { + return err + } + // Rejected key: continue into browser login below (must clear key first + // or we would re-enter this branch only). + fmt.Fprintln(os.Stdout, "Your saved API key is no longer valid. Starting browser sign-in...") + config.Profile.APIKey = "" + } else { + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") + ansi.StopSpinner(s, message, os.Stdout) + + config.Profile.ApplyValidateAPIKeyResponse(response, true) + + if err = config.Profile.SaveProfile(); err != nil { + return err + } + if err = config.Profile.UseProfile(); err != nil { + return err + } + + return nil } - - message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") - ansi.StopSpinner(s, message, os.Stdout) - - config.Profile.ApplyValidateAPIKeyResponse(response, true) - - if err = config.Profile.SaveProfile(); err != nil { - return err - } - if err = config.Profile.UseProfile(); err != nil { - return err - } - - return nil } parsedBaseURL, err := url.Parse(config.APIBaseURL) @@ -103,6 +110,8 @@ func Login(config *configpkg.Config, input io.Reader) error { return err } + configpkg.ResetAPIClient() + message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") ansi.StopSpinner(s, message, os.Stdout) diff --git a/pkg/login/client_login_test.go b/pkg/login/client_login_test.go new file mode 100644 index 00000000..5743d5e4 --- /dev/null +++ b/pkg/login/client_login_test.go @@ -0,0 +1,121 @@ +package login + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + configpkg "github.com/hookdeck/hookdeck-cli/pkg/config" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/require" +) + +// TestLogin_validateNonUnauthorizedStillFails verifies that credential +// verification errors other than 401 are returned immediately (no browser flow). +func TestLogin_validateNonUnauthorizedStillFails(t *testing.T) { + configpkg.ResetAPIClient() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/cli-auth/validate") { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"server boom"}`)) + return + } + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(ts.Close) + + cfg := &configpkg.Config{ + APIBaseURL: ts.URL, + DeviceName: "test-device", + LogLevel: "error", + TelemetryDisabled: true, + } + cfg.Profile = configpkg.Profile{ + Name: "default", + APIKey: "hk_test_123456789012", + Config: cfg, + } + + err := Login(cfg, strings.NewReader("\n")) + require.Error(t, err) +} + +// TestLogin_unauthorizedValidateStartsBrowserFlow checks that a 401 from +// validate is followed by POST /cli-auth (browser login), then a successful poll. +func TestLogin_unauthorizedValidateStartsBrowserFlow(t *testing.T) { + configpkg.ResetAPIClient() + t.Cleanup(configpkg.ResetAPIClientForTesting) + + oldCan := canOpenBrowser + oldOpen := openBrowser + canOpenBrowser = func() bool { return false } + openBrowser = func(string) error { return nil } + t.Cleanup(func() { + canOpenBrowser = oldCan + openBrowser = oldOpen + }) + + pollHits := 0 + var serverURL string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth"): + pollURL := serverURL + hookdeck.APIPathPrefix + "/cli-auth/poll?key=pollkey" + body, err := json.Marshal(map[string]string{ + "browser_url": "https://example.test/auth", + "poll_url": pollURL, + }) + require.NoError(t, err) + _, _ = w.Write(body) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/cli-auth/poll"): + pollHits++ + resp := map[string]interface{}{ + "claimed": true, + "key": "hk_test_newkey_abcdefghij", + "team_id": "tm_1", + "team_mode": "gateway", + "team_name": "Proj", + "user_name": "U", + "user_email": "u@example.com", + "organization_name": "Org", + "organization_id": "org_1", + "client_id": "cl_1", + } + enc, err := json.Marshal(resp) + require.NoError(t, err) + _, _ = w.Write(enc) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + serverURL = ts.URL + t.Cleanup(ts.Close) + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +api_key = "hk_test_oldkey_abcdefghij" +`), 0o600)) + + cfg, err := configpkg.LoadConfigFromFile(configPath) + require.NoError(t, err) + cfg.APIBaseURL = ts.URL + cfg.DeviceName = "test-device" + cfg.LogLevel = "error" + cfg.TelemetryDisabled = true + + err = Login(cfg, strings.NewReader("\n")) + require.NoError(t, err) + require.Equal(t, 1, pollHits, "poll should run once with immediate claimed=true") + require.Equal(t, "hk_test_newkey_abcdefghij", cfg.Profile.APIKey) +} diff --git a/test/acceptance/README.md b/test/acceptance/README.md index 12f7c5e4..cff8d60b 100644 --- a/test/acceptance/README.md +++ b/test/acceptance/README.md @@ -11,6 +11,8 @@ These tests run automatically in CI using API keys from `hookdeck ci`. They don' **Files:** Test files with **feature build tags** (e.g. `//go:build connection`, `//go:build request`). Each automated test file has exactly one feature tag so tests can be split into parallel slices (see [Parallelisation](#parallelisation)). +**Login recovery (mock API, `basic` tag):** `login_auth_acceptance_test.go` runs the real CLI with `--api-base` pointing at a local server that returns **401** on `GET .../cli-auth/validate`, then completes a fake device-auth poll — this asserts `hookdeck login` continues into browser/device flow after a stale key (no human, no real Hookdeck key). The same file includes **`TestCIFailsFastWithInvalidAPIKeyAcceptance`**, which runs `hookdeck ci --api-key` with a bogus key against the real API and expects a quick failure with the friendly **Authentication failed** message, and asserts output does **not** contain browser/device-login phrases (`Press Enter to open the browser`, `To authenticate with Hookdeck`, etc.) so CI never enters the interactive `hookdeck login` flow. + ### 2. Manual Tests (Require Human Interaction) These tests require browser-based authentication via `hookdeck login` and must be run manually by developers. diff --git a/test/acceptance/login_auth_acceptance_test.go b/test/acceptance/login_auth_acceptance_test.go new file mode 100644 index 00000000..febc8c01 --- /dev/null +++ b/test/acceptance/login_auth_acceptance_test.go @@ -0,0 +1,163 @@ +//go:build basic + +package acceptance + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/require" +) + +// TestLoginAfterValidate401StartsBrowserFlowAcceptance runs the real CLI against a local +// mock API: GET validate returns 401, then POST /cli-auth and poll complete the device flow. +// SSH_CONNECTION avoids the "Press Enter to open the browser" branch (non-interactive). +func TestLoginAfterValidate401StartsBrowserFlowAcceptance(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err) + mainGo := filepath.Join(projectRoot, "main.go") + + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +api_key = "hk_test_stale_accept01" +`), 0o600)) + + pollHits := 0 + var serverURL string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/cli-auth/validate"): + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Unauthorized")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cli-auth"): + pollURL := serverURL + hookdeck.APIPathPrefix + "/cli-auth/poll?key=pollkey" + body, encErr := json.Marshal(map[string]string{ + "browser_url": "https://example.test/auth", + "poll_url": pollURL, + }) + require.NoError(t, encErr) + _, _ = w.Write(body) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/cli-auth/poll"): + pollHits++ + resp := map[string]interface{}{ + "claimed": true, + "key": "hk_test_newkey_accept01", + "team_id": "tm_accept", + "team_mode": "gateway", + "team_name": "AcceptProj", + "user_name": "Accept", + "user_email": "accept@example.com", + "organization_name": "AcceptOrg", + "organization_id": "org_accept", + "client_id": "cl_accept", + } + enc, encErr := json.Marshal(resp) + require.NoError(t, encErr) + _, _ = w.Write(enc) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + serverURL = ts.URL + t.Cleanup(ts.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "go", append([]string{"run", mainGo, + "--api-base", ts.URL, + "--hookdeck-config", configPath, + "--log-level", "error", + "login", + })...) + cmd.Dir = projectRoot + env := appendEnvOverride(os.Environ(), "HOOKDECK_CONFIG_FILE", configPath) + env = appendEnvOverride(env, "SSH_CONNECTION", "acceptance-login-mock") + env = appendEnvOverride(env, "HOOKDECK_CLI_TELEMETRY_DISABLED", "1") + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + require.NoError(t, err, "stdout=%q stderr=%q", stdout.String(), stderr.String()) + require.Contains(t, stdout.String(), "no longer valid", "user should see stale-key message") + require.Equal(t, 1, pollHits, "mock should see exactly one poll after cli-auth") +} + +// TestCIFailsFastWithInvalidAPIKeyAcceptance verifies hookdeck ci does not enter the +// interactive browser login path when the project API key is invalid — it exits with +// an error quickly (CI-safe: no stdin / device flow). +func TestCIFailsFastWithInvalidAPIKeyAcceptance(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + projectRoot, err := filepath.Abs("../..") + require.NoError(t, err) + mainGo := filepath.Join(projectRoot, "main.go") + + // Isolated empty profile so we do not merge with a developer's global config. + configPath := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`profile = "default" + +[default] +`), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + invalidKey := "hk_test_ci_invalid_accept01" // valid shape, not a real key + cmd := exec.CommandContext(ctx, "go", append([]string{"run", mainGo, + "--hookdeck-config", configPath, + "--log-level", "error", + "ci", "--api-key", invalidKey, + })...) + cmd.Dir = projectRoot + env := appendEnvOverride(os.Environ(), "HOOKDECK_CONFIG_FILE", configPath) + env = appendEnvOverride(env, "HOOKDECK_CLI_TELEMETRY_DISABLED", "1") + cmd.Env = env + + start := time.Now() + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + require.Error(t, err, "ci with bogus API key must fail") + elapsed := time.Since(start) + require.Less(t, elapsed, 30*time.Second, "ci should fail quickly without waiting for interactive login; took %v", elapsed) + + combined := stdout.String() + "\n" + stderr.String() + require.Contains(t, combined, "Authentication failed", + "expected friendly auth message; stdout=%q stderr=%q", stdout.String(), stderr.String()) + + // hookdeck ci uses POST /cli-auth/ci only — it must never start the interactive + // browser/device login flow used by hookdeck login (pkg/login/client_login.go). + for _, phrase := range []string{ + "Press Enter to open the browser", + "To authenticate with Hookdeck, please go to:", + "Your saved API key is no longer valid", + "Starting browser sign-in", + "Waiting for confirmation", + } { + require.NotContains(t, combined, phrase, + "ci with invalid key must not trigger browser login; saw disallowed phrase %q in stdout=%q stderr=%q", + phrase, stdout.String(), stderr.String()) + } +} From 512b1b55ce64848ff144e59153bf4348dcea0372 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 9 Apr 2026 13:45:43 +0100 Subject: [PATCH 2/3] refactor(config): sync cached API client from Profile instead of resetting Replace exported ResetAPIClient with RefreshCachedAPIClient that updates the singleton in place (matches how MCP already mutates credentials on the shared *hookdeck.Client). Keep resetAPIClient unexported for tests only. MCP login continues to assign client.APIKey/ProjectID explicitly: tests use a dedicated client pointer, not the global singleton. Made-with: Cursor --- pkg/config/apiclient.go | 27 +++++++++++++++++++++++---- pkg/gateway/mcp/tool_login.go | 4 +++- pkg/login/client_login.go | 2 +- pkg/login/client_login_test.go | 4 ++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/pkg/config/apiclient.go b/pkg/config/apiclient.go index 7f0b03a2..e6d64cee 100644 --- a/pkg/config/apiclient.go +++ b/pkg/config/apiclient.go @@ -10,9 +10,7 @@ import ( var apiClient *hookdeck.Client var apiClientOnce sync.Once -// ResetAPIClient clears the cached API client singleton. The next GetAPIClient -// call builds a fresh client from the current config (used after login updates credentials). -func ResetAPIClient() { +func resetAPIClient() { apiClient = nil apiClientOnce = sync.Once{} } @@ -20,7 +18,28 @@ func ResetAPIClient() { // ResetAPIClientForTesting resets the global API client singleton so that // tests can start with a fresh instance. Must only be called from tests. func ResetAPIClientForTesting() { - ResetAPIClient() + resetAPIClient() +} + +// RefreshCachedAPIClient copies the current config (API base, profile key and +// project id, log/telemetry flags) onto the cached *hookdeck.Client if one +// already exists. Use after login or other in-process profile updates so the +// singleton matches Profile without discarding the underlying http.Client. +// If GetAPIClient has never been called, this is a no-op (the next GetAPIClient +// will construct from Config). +func (c *Config) RefreshCachedAPIClient() { + if apiClient == nil { + return + } + baseURL, err := url.Parse(c.APIBaseURL) + if err != nil { + panic("Invalid API base URL: " + err.Error()) + } + apiClient.BaseURL = baseURL + apiClient.APIKey = c.Profile.APIKey + apiClient.ProjectID = c.Profile.ProjectId + apiClient.Verbose = c.LogLevel == "debug" + apiClient.TelemetryDisabled = c.TelemetryDisabled } // GetAPIClient returns the internal API client instance diff --git a/pkg/gateway/mcp/tool_login.go b/pkg/gateway/mcp/tool_login.go index 99415d2a..2c118ddd 100644 --- a/pkg/gateway/mcp/tool_login.go +++ b/pkg/gateway/mcp/tool_login.go @@ -164,7 +164,9 @@ func handleLogin(srv *Server) mcpsdk.ToolHandler { cfg.SaveActiveProfileAfterLogin() - // Update the shared client so all resource tools start working. + // Update the server-held client (in production this is the same pointer as + // config.GetAPIClient(); tests inject a separate *hookdeck.Client, so we must + // mutate this handle — RefreshCachedAPIClient only touches the global singleton). client.APIKey = response.APIKey client.ProjectID = response.ProjectID org, proj, err := project.ParseProjectName(response.ProjectName) diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index e134d86a..f491f600 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -110,7 +110,7 @@ func Login(config *configpkg.Config, input io.Reader) error { return err } - configpkg.ResetAPIClient() + config.RefreshCachedAPIClient() message := SuccessMessage(response.UserName, response.UserEmail, response.OrganizationName, response.ProjectName, response.ProjectMode == "console") ansi.StopSpinner(s, message, os.Stdout) diff --git a/pkg/login/client_login_test.go b/pkg/login/client_login_test.go index 5743d5e4..a1fe1df3 100644 --- a/pkg/login/client_login_test.go +++ b/pkg/login/client_login_test.go @@ -17,7 +17,7 @@ import ( // TestLogin_validateNonUnauthorizedStillFails verifies that credential // verification errors other than 401 are returned immediately (no browser flow). func TestLogin_validateNonUnauthorizedStillFails(t *testing.T) { - configpkg.ResetAPIClient() + configpkg.ResetAPIClientForTesting() t.Cleanup(configpkg.ResetAPIClientForTesting) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -49,7 +49,7 @@ func TestLogin_validateNonUnauthorizedStillFails(t *testing.T) { // TestLogin_unauthorizedValidateStartsBrowserFlow checks that a 401 from // validate is followed by POST /cli-auth (browser login), then a successful poll. func TestLogin_unauthorizedValidateStartsBrowserFlow(t *testing.T) { - configpkg.ResetAPIClient() + configpkg.ResetAPIClientForTesting() t.Cleanup(configpkg.ResetAPIClientForTesting) oldCan := canOpenBrowser From 431afd14016b071be58f2ea084eb01698c9a0534 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:55:46 +0000 Subject: [PATCH 3/3] Update package.json version to 2.1.2-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92d7c374..b7d056b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "2.1.1", + "version": "2.1.2-beta.1", "description": "Hookdeck CLI", "repository": { "type": "git",