From 2c944997ca50724ccc6cad37ee95c4648946ad38 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 1 Sep 2026 00:14:48 -0700 Subject: [PATCH 1/2] Enable the SDK's ETag response cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI has run with CacheEnabled: false since the SDK migration (#23), so no request ever revalidated with If-None-Match and every read refetched full bodies the server had already told us hadn't changed. The SDK cache is a revalidation cache: every read still asks the server, conditionally, and only a 304 is answered from disk — so enabling it can never serve stale mail. The cache lives under the CLI's cache directory at hey-cli/http, and logout drops it so cached mail does not outlive the credentials that fetched it. When no cache location resolves, caching stays off. Generated operations start revalidating once the SDK dependency carries basecamp/hey-sdk#140; until then the cache covers the hand-written request paths and is inert elsewhere. --- internal/cmd/auth.go | 2 ++ internal/cmd/sdk.go | 28 ++++++++++++++++-- internal/cmd/sdk_cache_test.go | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/sdk_cache_test.go diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 65549791..c7a5232c 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -127,6 +127,8 @@ func buildLogoutCommand(path string) *cobra.Command { if err := authMgr.Logout(); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not clear credentials: %v", err)) } + // Cached mail must not outlive the credentials that fetched it. + clearHTTPCache() return writeMutation(cmd, "Logged out", nil) }, } diff --git a/internal/cmd/sdk.go b/internal/cmd/sdk.go index 91e862cf..53ce6d81 100644 --- a/internal/cmd/sdk.go +++ b/internal/cmd/sdk.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "os" + "path/filepath" "strconv" "strings" "sync/atomic" @@ -76,8 +77,15 @@ var sdkStats *statsHooks // initSDK creates the SDK client, bridging the CLI's auth and config. func initSDK(authMgr *auth.Manager, baseURL string) { sdkCfg := &hey.Config{ - BaseURL: baseURL, - CacheEnabled: false, + BaseURL: baseURL, + } + + // The SDK cache is a revalidation cache: every read still asks the server, + // conditionally, and only a 304 is answered from disk — so it can never serve + // stale mail. Logout clears it so cached mail does not outlive its credentials. + if dir := httpCacheDir(); dir != "" { + sdkCfg.CacheDir = dir + sdkCfg.CacheEnabled = true } var opts []hey.ClientOption @@ -97,6 +105,22 @@ func initSDK(authMgr *auth.Manager, baseURL string) { sdk = rootSDK } +// httpCacheDir is where the SDK keeps its ETag response cache, or empty when no +// cache location can be resolved, which leaves caching off. +func httpCacheDir() string { + if dir := config.CacheDir(); dir != "" { + return filepath.Join(dir, "http") + } + return "" +} + +// clearHTTPCache drops the SDK's response cache. +func clearHTTPCache() { + if dir := httpCacheDir(); dir != "" { + _ = hey.NewCache(dir).Clear() + } +} + // newSDKClient builds another client sharing the CLI's configuration — auth, // user agent, hooks, logging — plus any extra options. Valid after initSDK. func newSDKClient(extra ...hey.ClientOption) *hey.Client { diff --git a/internal/cmd/sdk_cache_test.go b/internal/cmd/sdk_cache_test.go new file mode 100644 index 00000000..8ad13631 --- /dev/null +++ b/internal/cmd/sdk_cache_test.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/basecamp/hey-cli/internal/auth" +) + +func TestInitSDKEnablesTheRevalidationCache(t *testing.T) { + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + initSDK(auth.NewManager("https://app.hey.com", nil, t.TempDir()), "https://app.hey.com") + + if !sdkClientCfg.CacheEnabled { + t.Error("expected the SDK cache enabled") + } + if want := filepath.Join(cacheHome, "hey-cli", "http"); sdkClientCfg.CacheDir != want { + t.Errorf("cache dir = %q, want %q", sdkClientCfg.CacheDir, want) + } +} + +func TestLogoutClearsTheHTTPCache(t *testing.T) { + configHome := t.TempDir() + responses := filepath.Join(configHome, "hey-cli", "http", "responses") + if err := os.MkdirAll(responses, 0o700); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{ + filepath.Join(responses, "abc123.body"): `{"cached":"mail"}`, + filepath.Join(configHome, "hey-cli", "http", "etags.json"): `{"abc123":"\"v1\""}`, + } { + if err := os.WriteFile(name, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + if _, _, err := runAuthCommand(t, configHome, "https://app.hey.com", "", true, "auth", "login", "--cookie", "session-cookie"); err != nil { + t.Fatalf("auth login: %v", err) + } + if _, logout, err := runAuthCommand(t, configHome, "https://app.hey.com", "", true, "auth", "logout"); err != nil || logout.Summary != "Logged out" { + t.Fatalf("auth logout: %v (%q)", err, logout.Summary) + } + + if _, err := os.Stat(responses); !os.IsNotExist(err) { + t.Error("expected logout to drop the cached responses") + } + if _, err := os.Stat(filepath.Join(configHome, "hey-cli", "http", "etags.json")); !os.IsNotExist(err) { + t.Error("expected logout to drop the cached ETags") + } +} From 1c79b05e2c777af0e52b8c037fdb23528f6eb7e6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 1 Sep 2026 00:40:33 -0700 Subject: [PATCH 2/2] Clear the response cache whenever credentials change hands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login over standing credentials — token, cookie, or OAuth, setup's flow included — replaces them and orphans whatever the old ones cached, so the replacement clears the cache without waiting for a logout. A clear that fails no longer vanishes: the warning names the directory left to remove by hand, after the credential change it trailed has already happened. --- internal/cmd/auth.go | 8 +++++++- internal/cmd/sdk.go | 16 ++++++++++++---- internal/cmd/sdk_cache_test.go | 29 +++++++++++++++++++++++++++++ internal/cmd/setup.go | 2 ++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index c7a5232c..50c2403d 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -71,6 +71,8 @@ Use --token or --cookie for non-interactive login.`, if err := authMgr.LoginWithToken(token); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not save token: %v", err)) } + // Replaced credentials orphan whatever the old ones cached. + clearHTTPCache(cmd.ErrOrStderr()) return writeMutation(cmd, "Logged in with token", map[string]string{"method": "token"}) } @@ -78,6 +80,8 @@ Use --token or --cookie for non-interactive login.`, if err := authMgr.LoginWithCookie(cookie); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not save cookie: %v", err)) } + // Replaced credentials orphan whatever the old ones cached. + clearHTTPCache(cmd.ErrOrStderr()) return writeMutation(cmd, "Logged in with session cookie", map[string]string{"method": "cookie"}) } @@ -87,6 +91,8 @@ Use --token or --cookie for non-interactive login.`, if err := authMgr.Login(ctx, auth.LoginOptions{NoBrowser: noBrowser}); err != nil { return apierr.ErrAuth(fmt.Sprintf("login failed: %v", err)) } + // Replaced credentials orphan whatever the old ones cached. + clearHTTPCache(cmd.ErrOrStderr()) if writer.IsStyled() { w := cmd.OutOrStdout() @@ -128,7 +134,7 @@ func buildLogoutCommand(path string) *cobra.Command { return apierr.ErrAuth(fmt.Sprintf("could not clear credentials: %v", err)) } // Cached mail must not outlive the credentials that fetched it. - clearHTTPCache() + clearHTTPCache(cmd.ErrOrStderr()) return writeMutation(cmd, "Logged out", nil) }, } diff --git a/internal/cmd/sdk.go b/internal/cmd/sdk.go index 53ce6d81..c064d2d4 100644 --- a/internal/cmd/sdk.go +++ b/internal/cmd/sdk.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "net/http" "os" @@ -114,10 +115,17 @@ func httpCacheDir() string { return "" } -// clearHTTPCache drops the SDK's response cache. -func clearHTTPCache() { - if dir := httpCacheDir(); dir != "" { - _ = hey.NewCache(dir).Clear() +// clearHTTPCache drops the SDK's response cache. It runs after a credential +// change has already happened, so a failure is reported rather than failing +// the command that carried it: the warning names the directory left to +// remove by hand. +func clearHTTPCache(errOut io.Writer) { + dir := httpCacheDir() + if dir == "" { + return + } + if err := hey.NewCache(dir).Clear(); err != nil { + fmt.Fprintf(errOut, "warning: could not clear cached responses in %s: %v\n", dir, err) } } diff --git a/internal/cmd/sdk_cache_test.go b/internal/cmd/sdk_cache_test.go index 8ad13631..ef5255d1 100644 --- a/internal/cmd/sdk_cache_test.go +++ b/internal/cmd/sdk_cache_test.go @@ -22,6 +22,35 @@ func TestInitSDKEnablesTheRevalidationCache(t *testing.T) { } } +// A login over standing credentials replaces them, orphaning whatever the old +// ones cached: the replacement clears the cache without waiting for a logout. +func TestLoginReplacingCredentialsClearsTheHTTPCache(t *testing.T) { + configHome := t.TempDir() + responses := filepath.Join(configHome, "hey-cli", "http", "responses") + if err := os.MkdirAll(responses, 0o700); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{ + filepath.Join(responses, "abc123.body"): `{"cached":"mail"}`, + filepath.Join(configHome, "hey-cli", "http", "etags.json"): `{"abc123":"\"v1\""}`, + } { + if err := os.WriteFile(name, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + if _, _, err := runAuthCommand(t, configHome, "https://app.hey.com", "", true, "auth", "login", "--cookie", "replacement-cookie"); err != nil { + t.Fatalf("auth login: %v", err) + } + + if _, err := os.Stat(responses); !os.IsNotExist(err) { + t.Error("expected login to drop the previous credentials' cached responses") + } + if _, err := os.Stat(filepath.Join(configHome, "hey-cli", "http", "etags.json")); !os.IsNotExist(err) { + t.Error("expected login to drop the previous credentials' cached ETags") + } +} + func TestLogoutClearsTheHTTPCache(t *testing.T) { configHome := t.TempDir() responses := filepath.Join(configHome, "hey-cli", "http", "responses") diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index f21fa311..28978c42 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -296,6 +296,8 @@ var loginInteractively = func(out io.Writer) error { if err := authMgr.Login(ctx, auth.LoginOptions{Logger: logger}); err != nil { return apierr.ErrAuth(fmt.Sprintf("login failed: %v", err)) } + // Replaced credentials orphan whatever the old ones cached. + clearHTTPCache(out) return selectConfiguredAccount(context.Background()) }