From 6102bc8fb84f864a75fbe692e50b860a2d5aad64 Mon Sep 17 00:00:00 2001 From: Lobster Date: Sat, 14 Mar 2026 23:59:42 -0700 Subject: [PATCH 1/5] fix(client): use form-urlencoded for OAuth token endpoint The Eight Sleep auth server (auth-api.8slp.net/v1/tokens) expects standard OAuth2 form-urlencoded requests, not JSON. The previous implementation sent JSON with hardcoded "sleep-client" credentials, which caused a 400 from Joi validation. The fallback to legacy /login then tripped the rate limiter, resulting in a permanent 429 loop. Changes: - Send application/x-www-form-urlencoded instead of application/json - Use c.ClientID and c.ClientSecret (the real app creds extracted from the Android APK) instead of hardcoded "sleep-client"/"" - Make authURL a var so tests can point it at a local server - Add tests for form encoding, credential passthrough, and legacy login fallback Fixes #7, fixes #8, fixes #12 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + internal/client/eightsleep.go | 24 ++++---- internal/client/eightsleep_test.go | 95 ++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index aaadf73..929a9b5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ +eightctl diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 55fad67..78b17fa 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -18,12 +18,14 @@ import ( const ( defaultBaseURL = "https://client-api.8slp.net/v1" - authURL = "https://auth-api.8slp.net/v1/tokens" // Extracted from the official Eight Sleep Android app v7.39.17 (public client creds) defaultClientID = "0894c7f33bb94800a03f1f4df13a4f38" defaultClientSecret = "f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76" ) +// authURL is a var so tests can point it at a local server. +var authURL = "https://auth-api.8slp.net/v1/tokens" + // Client represents Eight Sleep API client. type Client struct { Email string @@ -116,19 +118,19 @@ func (c *Client) EnsureDeviceID(ctx context.Context) (string, error) { } func (c *Client) authTokenEndpoint(ctx context.Context) error { - payload := map[string]string{ - "grant_type": "password", - "username": c.Email, - "password": c.Password, - "client_id": "sleep-client", - "client_secret": "", - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader(body)) + form := url.Values{ + "grant_type": {"password"}, + "username": {c.Email}, + "password": {c.Password}, + "client_id": {c.ClientID}, + "client_secret": {c.ClientSecret}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, + bytes.NewReader([]byte(form.Encode()))) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTP.Do(req) if err != nil { diff --git a/internal/client/eightsleep_test.go b/internal/client/eightsleep_test.go index a6204ac..1ff96c5 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -2,6 +2,8 @@ package client import ( "context" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -70,6 +72,99 @@ func TestRequireUserFilledAutomatically(t *testing.T) { } } +func TestAuthTokenEndpoint_FormEncoded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Must be form-encoded, not JSON. + ct := r.Header.Get("Content-Type") + if ct != "application/x-www-form-urlencoded" { + t.Errorf("expected form-urlencoded, got %s", ct) + http.Error(w, "bad content type", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + // Verify correct client credentials are sent (not "sleep-client"). + if got := r.PostFormValue("client_id"); got != defaultClientID { + t.Errorf("client_id = %q, want %q", got, defaultClientID) + } + if got := r.PostFormValue("client_secret"); got != defaultClientSecret { + t.Errorf("client_secret = %q, want %q", got, defaultClientSecret) + } + if got := r.PostFormValue("grant_type"); got != "password" { + t.Errorf("grant_type = %q, want password", got) + } + if got := r.PostFormValue("username"); got != "test@example.com" { + t.Errorf("username = %q, want test@example.com", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "tok-123", + "expires_in": 3600, + "userId": "uid-abc", + }) + })) + defer srv.Close() + + old := authURL + authURL = srv.URL + defer func() { authURL = old }() + + c := New("test@example.com", "secret", "", "", "") + c.HTTP = srv.Client() + + if err := c.Authenticate(context.Background()); err != nil { + t.Fatalf("Authenticate: %v", err) + } + if c.token != "tok-123" { + t.Errorf("token = %q, want tok-123", c.token) + } + if c.UserID != "uid-abc" { + t.Errorf("UserID = %q, want uid-abc", c.UserID) + } +} + +func TestAuthTokenEndpoint_FallsBackToLegacy(t *testing.T) { + tokenCalled := false + legacyCalled := false + + mux := http.NewServeMux() + // Token endpoint fails + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + tokenCalled = true + http.Error(w, "nope", http.StatusBadRequest) + }) + // Legacy login succeeds + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + legacyCalled = true + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"session":{"token":"legacy-tok","userId":"uid-legacy","expirationDate":"2099-01-01T00:00:00Z"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + old := authURL + authURL = srv.URL + "/token" + defer func() { authURL = old }() + + c := New("test@example.com", "secret", "", "", "") + c.BaseURL = srv.URL + c.HTTP = srv.Client() + + if err := c.Authenticate(context.Background()); err != nil { + t.Fatalf("Authenticate: %v", err) + } + if !tokenCalled { + t.Error("token endpoint was not tried") + } + if !legacyCalled { + t.Error("legacy login was not tried after token failure") + } + if c.token != "legacy-tok" { + t.Errorf("token = %q, want legacy-tok", c.token) + } +} + func Test429Retry(t *testing.T) { count := 0 mux := http.NewServeMux() From 2857d0c1f08fbf3879441b0de424a9a1e2fe74d8 Mon Sep 17 00:00:00 2001 From: Lobster Date: Sun, 15 Mar 2026 08:30:28 -0700 Subject: [PATCH 2/5] fix(client): decompress gzip API responses The do() method sends Accept-Encoding: gzip but never decompresses the response body, causing json.Decode to fail with: invalid character '\x1f' looking for beginning of value (0x1f is the gzip magic byte.) Check Content-Encoding: gzip on responses and wrap the body in a gzip.Reader before decoding. Added test with a mock gzip response. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/client/eightsleep.go | 12 +++++++++- internal/client/eightsleep_test.go | 36 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 78b17fa..52adbae 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -2,6 +2,7 @@ package client import ( "bytes" + "compress/gzip" "context" "crypto/tls" "encoding/json" @@ -307,7 +308,16 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, return fmt.Errorf("api %s %s: %s", method, path, string(b)) } if out != nil { - return json.NewDecoder(resp.Body).Decode(out) + r := io.Reader(resp.Body) + if resp.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("gzip decode: %w", err) + } + defer gz.Close() + r = gz + } + return json.NewDecoder(r).Decode(out) } return nil } diff --git a/internal/client/eightsleep_test.go b/internal/client/eightsleep_test.go index 1ff96c5..f435568 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -1,6 +1,8 @@ package client import ( + "bytes" + "compress/gzip" "context" "encoding/json" "fmt" @@ -165,6 +167,40 @@ func TestAuthTokenEndpoint_FallsBackToLegacy(t *testing.T) { } } +func TestGzipResponseDecoded(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/users/me", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"user":{"userId":"uid-gz","currentDevice":{"id":"dev-gz"}}}`)) + }) + mux.HandleFunc("/users/uid-gz/temperature", func(w http.ResponseWriter, r *http.Request) { + // Respond with gzip-encoded body + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + gz.Write([]byte(`{"currentLevel":42,"currentState":{"type":"on"}}`)) + gz.Close() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + w.Write(buf.Bytes()) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New("e", "p", "", "", "") + c.BaseURL = srv.URL + c.token = "t" + c.tokenExp = time.Now().Add(time.Hour) + c.HTTP = srv.Client() + + st, err := c.GetStatus(context.Background()) + if err != nil { + t.Fatalf("GetStatus with gzip response: %v", err) + } + if st.CurrentLevel != 42 { + t.Errorf("CurrentLevel = %d, want 42", st.CurrentLevel) + } +} + func Test429Retry(t *testing.T) { count := 0 mux := http.NewServeMux() From 9f49cf0a1655b0eed256de02b49ae7d65d7f4956 Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Wed, 15 Apr 2026 14:54:35 +0000 Subject: [PATCH 3/5] fix: cap retry loops, simplify gzip handling, fix keyring hang in headless envs - Replace infinite 429/401 retry loops with bounded retries (max 3) and exponential backoff to prevent permanent rate-limit storms - Remove explicit Accept-Encoding: gzip header; let Go's http.Transport handle compression transparently (simpler, no manual gzip.NewReader) - Detect headless environments (SSH, no TTY, EIGHTCTL_KEYRING_FILE=1) and fall back to file-based keyring to avoid macOS Keychain hang Credit to @davidfencik (#16) for the retry and keyring patterns, and @petersentaylor (#27) for the simpler gzip approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/client/eightsleep.go | 35 ++++++++-------- internal/client/eightsleep_test.go | 64 ++++++++++++++++++++---------- internal/tokencache/tokencache.go | 21 ++++++---- 3 files changed, 76 insertions(+), 44 deletions(-) diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 52adbae..cdf2961 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -2,7 +2,6 @@ package client import ( "bytes" - "compress/gzip" "context" "crypto/tls" "encoding/json" @@ -185,7 +184,6 @@ func (c *Client) authLegacyLogin(ctx context.Context) error { req.Header.Set("Accept", "application/json") req.Header.Set("Connection", "keep-alive") req.Header.Set("User-Agent", "okhttp/4.9.3") - req.Header.Set("Accept-Encoding", "gzip") resp, err := c.HTTP.Do(req) if err != nil { return err @@ -259,7 +257,13 @@ func (c *Client) requireUser(ctx context.Context) error { return c.EnsureUserID(ctx) } +const maxRetries = 3 + func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any, out any) error { + return c.doRetry(ctx, method, path, query, body, out, 0) +} + +func (c *Client) doRetry(ctx context.Context, method, path string, query url.Values, body any, out any, attempt int) error { if err := c.ensureToken(ctx); err != nil { return err } @@ -284,7 +288,9 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, req.Header.Set("Accept", "application/json") req.Header.Set("Connection", "keep-alive") req.Header.Set("User-Agent", "okhttp/4.9.3") - req.Header.Set("Accept-Encoding", "gzip") + // Note: we intentionally do NOT set Accept-Encoding. Go's http.Transport + // handles gzip transparently when the header is absent. Setting it + // explicitly disables automatic decompression. resp, err := c.HTTP.Do(req) if err != nil { @@ -292,32 +298,29 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { - time.Sleep(2 * time.Second) - return c.do(ctx, method, path, query, body, out) + if attempt >= maxRetries { + return fmt.Errorf("rate limited after %d retries: %s %s", maxRetries, method, path) + } + time.Sleep(time.Duration(2*(attempt+1)) * time.Second) + return c.doRetry(ctx, method, path, query, body, out, attempt+1) } if resp.StatusCode == http.StatusUnauthorized { + if attempt >= maxRetries { + return fmt.Errorf("unauthorized after %d retries: %s %s", maxRetries, method, path) + } c.token = "" _ = tokencache.Clear(c.Identity()) if err := c.ensureToken(ctx); err != nil { return err } - return c.do(ctx, method, path, query, body, out) + return c.doRetry(ctx, method, path, query, body, out, attempt+1) } if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("api %s %s: %s", method, path, string(b)) } if out != nil { - r := io.Reader(resp.Body) - if resp.Header.Get("Content-Encoding") == "gzip" { - gz, err := gzip.NewReader(resp.Body) - if err != nil { - return fmt.Errorf("gzip decode: %w", err) - } - defer gz.Close() - r = gz - } - return json.NewDecoder(r).Decode(out) + return json.NewDecoder(resp.Body).Decode(out) } return nil } diff --git a/internal/client/eightsleep_test.go b/internal/client/eightsleep_test.go index f435568..02b0787 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -1,8 +1,6 @@ package client import ( - "bytes" - "compress/gzip" "context" "encoding/json" "fmt" @@ -167,37 +165,34 @@ func TestAuthTokenEndpoint_FallsBackToLegacy(t *testing.T) { } } -func TestGzipResponseDecoded(t *testing.T) { +func TestNoExplicitGzipHeader(t *testing.T) { + // Verify we don't send Accept-Encoding: gzip manually, so Go's + // http.Transport handles decompression transparently. mux := http.NewServeMux() - mux.HandleFunc("/users/me", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"user":{"userId":"uid-gz","currentDevice":{"id":"dev-gz"}}}`)) - }) - mux.HandleFunc("/users/uid-gz/temperature", func(w http.ResponseWriter, r *http.Request) { - // Respond with gzip-encoded body - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - gz.Write([]byte(`{"currentLevel":42,"currentState":{"type":"on"}}`)) - gz.Close() + mux.HandleFunc("/check", func(w http.ResponseWriter, r *http.Request) { + ae := r.Header.Get("Accept-Encoding") + // Go's Transport adds its own Accept-Encoding when we don't set one. + // The key assertion: our code must NOT set it to exactly "gzip". w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Encoding", "gzip") - w.Write(buf.Bytes()) + json.NewEncoder(w).Encode(map[string]string{"accept_encoding": ae}) }) srv := httptest.NewServer(mux) defer srv.Close() - c := New("e", "p", "", "", "") + c := New("e", "p", "uid", "", "") c.BaseURL = srv.URL c.token = "t" c.tokenExp = time.Now().Add(time.Hour) c.HTTP = srv.Client() - st, err := c.GetStatus(context.Background()) - if err != nil { - t.Fatalf("GetStatus with gzip response: %v", err) + var out map[string]string + if err := c.do(context.Background(), http.MethodGet, "/check", nil, nil, &out); err != nil { + t.Fatalf("do: %v", err) } - if st.CurrentLevel != 42 { - t.Errorf("CurrentLevel = %d, want 42", st.CurrentLevel) + // Go's transport sets "gzip" automatically but handles decompression. + // We just verify our code didn't break anything. + if out["accept_encoding"] == "" { + t.Fatal("expected Accept-Encoding to be set by Go's transport") } } @@ -232,3 +227,30 @@ func Test429Retry(t *testing.T) { t.Fatalf("expected backoff, got %v", elapsed) } } + +func Test429RetryCapped(t *testing.T) { + // Verify retries are bounded: after maxRetries, return an error. + count := 0 + mux := http.NewServeMux() + mux.HandleFunc("/always429", func(w http.ResponseWriter, r *http.Request) { + count++ + w.WriteHeader(http.StatusTooManyRequests) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New("email", "pass", "uid", "", "") + c.BaseURL = srv.URL + c.token = "t" + c.tokenExp = time.Now().Add(time.Hour) + c.HTTP = srv.Client() + + err := c.do(context.Background(), http.MethodGet, "/always429", nil, nil, nil) + if err == nil { + t.Fatal("expected error after exhausting retries") + } + // 1 initial + maxRetries = 4 total attempts + if count != maxRetries+1 { + t.Fatalf("expected %d attempts, got %d", maxRetries+1, count) + } +} diff --git a/internal/tokencache/tokencache.go b/internal/tokencache/tokencache.go index 9d1c6a6..4a98bf3 100644 --- a/internal/tokencache/tokencache.go +++ b/internal/tokencache/tokencache.go @@ -43,14 +43,21 @@ func SetOpenKeyringForTest(fn func() (keyring.Keyring, error)) (restore func()) func defaultOpenKeyring() (keyring.Keyring, error) { home, _ := os.UserHomeDir() + backends := []keyring.BackendType{ + keyring.KeychainBackend, + keyring.SecretServiceBackend, + keyring.WinCredBackend, + keyring.FileBackend, + } + // In headless environments (SSH, no terminal, launchd agents) the macOS + // Keychain backend hangs indefinitely waiting for an authorization prompt. + // Fall back to file-only storage in those cases. + if os.Getenv("SSH_TTY") != "" || os.Getenv("TERM") == "" || os.Getenv("EIGHTCTL_KEYRING_FILE") == "1" { + backends = []keyring.BackendType{keyring.FileBackend} + } return keyring.Open(keyring.Config{ - ServiceName: serviceName, - AllowedBackends: []keyring.BackendType{ - keyring.KeychainBackend, - keyring.SecretServiceBackend, - keyring.WinCredBackend, - keyring.FileBackend, - }, + ServiceName: serviceName, + AllowedBackends: backends, FileDir: filepath.Join(home, ".config", "eightctl", "keyring"), FilePasswordFunc: filePassword, }) From 1d2889cbbde6ca23d8c078d14d8751a162ac01fe Mon Sep 17 00:00:00 2001 From: Lobster Date: Wed, 15 Apr 2026 08:29:23 -0700 Subject: [PATCH 4/5] fix(client): drop legacy /login fallback, OAuth-only auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy /login endpoint no longer works reliably upstream, and the silent fallback was masking real OAuth errors. Remove it so OAuth failures surface directly. Also revert the keyring-backend tweaks from 9f49cf0 — leave tokencache behavior identical to main. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/spec.md | 2 +- internal/client/eightsleep.go | 64 +----------------------------- internal/client/eightsleep_test.go | 36 +++-------------- internal/tokencache/tokencache.go | 21 ++++------ 4 files changed, 16 insertions(+), 107 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 56660f9..b5dabd7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -8,7 +8,7 @@ Eight Sleep Pod power/control + data-export CLI, written in Go. Targets macOS/Li - Default OAuth client creds extracted from Android APK 7.39.17: - `client_id`: `0894c7f33bb94800a03f1f4df13a4f38` - `client_secret`: `f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76` -- Auth flow: password grant at `https://auth-api.8slp.net/v1/tokens`; fallback legacy `/login` session token. +- Auth flow: OAuth password grant at `https://auth-api.8slp.net/v1/tokens` (form-urlencoded). - Throttling: 429s observed; client retries with small delay and re-auths on 401. ## Configuration & Auth diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index cdf2961..32a92bd 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -67,12 +67,9 @@ func New(email, password, userID, clientID, clientSecret string) *Client { } } -// Authenticate fetches bearer token. Tries OAuth token endpoint first; falls back to /login used by app. +// Authenticate fetches a bearer token via the OAuth password-grant endpoint. func (c *Client) Authenticate(ctx context.Context) error { - if err := c.authTokenEndpoint(ctx); err == nil { - return nil - } - return c.authLegacyLogin(ctx) + return c.authTokenEndpoint(ctx) } // EnsureUserID populates UserID by calling /users/me if missing. @@ -170,63 +167,6 @@ func (c *Client) authTokenEndpoint(ctx context.Context) error { return nil } -func (c *Client) authLegacyLogin(ctx context.Context) error { - payload := map[string]string{ - "email": c.Email, - "password": c.Password, - } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/login", bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json; charset=UTF-8") - req.Header.Set("Accept", "application/json") - req.Header.Set("Connection", "keep-alive") - req.Header.Set("User-Agent", "okhttp/4.9.3") - resp, err := c.HTTP.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - log.Debug("legacy login failed", "status", resp.Status, "headers", resp.Header, "body", string(b)) - return fmt.Errorf("login failed: %s", string(b)) - } - var res struct { - Session struct { - UserID string `json:"userId"` - Token string `json:"token"` - ExpirationDate string `json:"expirationDate"` - } `json:"session"` - } - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return err - } - if res.Session.Token == "" { - return errors.New("empty session token") - } - c.token = res.Session.Token - if res.Session.ExpirationDate != "" { - if t, err := time.Parse(time.RFC3339, res.Session.ExpirationDate); err == nil { - c.tokenExp = t - } - } - if c.tokenExp.IsZero() { - c.tokenExp = time.Now().Add(12 * time.Hour) - } - if c.UserID == "" { - c.UserID = res.Session.UserID - } - if err := tokencache.Save(c.Identity(), c.token, c.tokenExp, c.UserID); err != nil { - log.Debug("failed to cache token", "error", err) - } else { - log.Debug("saved token to cache (legacy)", "expires_at", c.tokenExp) - } - return nil -} - func (c *Client) ensureToken(ctx context.Context) error { if c.token != "" && time.Now().Before(c.tokenExp) { log.Debug("using in-memory token", "expires_in", time.Until(c.tokenExp).Round(time.Second)) diff --git a/internal/client/eightsleep_test.go b/internal/client/eightsleep_test.go index 02b0787..4a4f821 100644 --- a/internal/client/eightsleep_test.go +++ b/internal/client/eightsleep_test.go @@ -3,7 +3,6 @@ package client import ( "context" "encoding/json" - "fmt" "net/http" "net/http/httptest" "testing" @@ -124,44 +123,21 @@ func TestAuthTokenEndpoint_FormEncoded(t *testing.T) { } } -func TestAuthTokenEndpoint_FallsBackToLegacy(t *testing.T) { - tokenCalled := false - legacyCalled := false - - mux := http.NewServeMux() - // Token endpoint fails - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { - tokenCalled = true +func TestAuthTokenEndpoint_ReturnsErrorOnFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "nope", http.StatusBadRequest) - }) - // Legacy login succeeds - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - legacyCalled = true - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"session":{"token":"legacy-tok","userId":"uid-legacy","expirationDate":"2099-01-01T00:00:00Z"}}`) - }) - srv := httptest.NewServer(mux) + })) defer srv.Close() old := authURL - authURL = srv.URL + "/token" + authURL = srv.URL defer func() { authURL = old }() c := New("test@example.com", "secret", "", "", "") - c.BaseURL = srv.URL c.HTTP = srv.Client() - if err := c.Authenticate(context.Background()); err != nil { - t.Fatalf("Authenticate: %v", err) - } - if !tokenCalled { - t.Error("token endpoint was not tried") - } - if !legacyCalled { - t.Error("legacy login was not tried after token failure") - } - if c.token != "legacy-tok" { - t.Errorf("token = %q, want legacy-tok", c.token) + if err := c.Authenticate(context.Background()); err == nil { + t.Fatal("Authenticate: expected error, got nil") } } diff --git a/internal/tokencache/tokencache.go b/internal/tokencache/tokencache.go index 4a98bf3..9d1c6a6 100644 --- a/internal/tokencache/tokencache.go +++ b/internal/tokencache/tokencache.go @@ -43,21 +43,14 @@ func SetOpenKeyringForTest(fn func() (keyring.Keyring, error)) (restore func()) func defaultOpenKeyring() (keyring.Keyring, error) { home, _ := os.UserHomeDir() - backends := []keyring.BackendType{ - keyring.KeychainBackend, - keyring.SecretServiceBackend, - keyring.WinCredBackend, - keyring.FileBackend, - } - // In headless environments (SSH, no terminal, launchd agents) the macOS - // Keychain backend hangs indefinitely waiting for an authorization prompt. - // Fall back to file-only storage in those cases. - if os.Getenv("SSH_TTY") != "" || os.Getenv("TERM") == "" || os.Getenv("EIGHTCTL_KEYRING_FILE") == "1" { - backends = []keyring.BackendType{keyring.FileBackend} - } return keyring.Open(keyring.Config{ - ServiceName: serviceName, - AllowedBackends: backends, + ServiceName: serviceName, + AllowedBackends: []keyring.BackendType{ + keyring.KeychainBackend, + keyring.SecretServiceBackend, + keyring.WinCredBackend, + keyring.FileBackend, + }, FileDir: filepath.Join(home, ".config", "eightctl", "keyring"), FilePasswordFunc: filePassword, }) From b573230f9dc91b0010c60d662df5aa4f8fabb381 Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Wed, 15 Apr 2026 15:34:03 +0000 Subject: [PATCH 5/5] fix(ci): remove contradictory golangci-lint config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disable-all: true` and `disable: [errcheck, unused]` can't be combined — golangci-lint errors with "can't combine options --disable-all and --disable". Remove the redundant `disable` list and stale `revive` settings since only `govet` is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 14 +++++++------- .golangci.yml | 20 ++++---------------- internal/client/eightsleep.go | 13 ++++++------- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fdbd52..713e1fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,22 +27,22 @@ jobs: key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: ${{ runner.os }}-go- - - name: Install tools - run: | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.1 - go install mvdan.cc/gofumpt@v0.6.0 + - name: Install gofumpt + run: go install mvdan.cc/gofumpt@v0.7.0 - name: Format check (gofumpt) run: | - # list files that would change CHANGED=$(gofumpt -l .) if [ -n "$CHANGED" ]; then - echo "gofumpt would reformat:\n$CHANGED" >&2 + echo "gofumpt would reformat:" >&2 + echo "$CHANGED" >&2 exit 1 fi - name: Lint - run: golangci-lint run ./... + uses: golangci/golangci-lint-action@v7 + with: + version: v2.1 - name: Test run: go test ./... diff --git a/.golangci.yml b/.golangci.yml index 463eac3..78c9781 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,26 +1,14 @@ -version: 2 +version: "2" run: timeout: 3m tests: true + linters: - disable-all: true - disable: - - errcheck - - unused + default: none enable: - govet - # revive and errcheck disabled to keep CLI glue concise - # - revive - # - errcheck -linters-settings: - revive: - rules: - - name: unused-parameter - disabled: true - - name: package-comments - disabled: true + issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/internal/client/eightsleep.go b/internal/client/eightsleep.go index 32a92bd..33860b7 100644 --- a/internal/client/eightsleep.go +++ b/internal/client/eightsleep.go @@ -115,13 +115,12 @@ func (c *Client) EnsureDeviceID(ctx context.Context) (string, error) { } func (c *Client) authTokenEndpoint(ctx context.Context) error { - form := url.Values{ - "grant_type": {"password"}, - "username": {c.Email}, - "password": {c.Password}, - "client_id": {c.ClientID}, - "client_secret": {c.ClientSecret}, - } + form := url.Values{} + form.Set("grant_type", "password") + form.Set("username", c.Email) + form.Set("password", c.Password) + form.Set("client_id", c.ClientID) + form.Set("client_secret", c.ClientSecret) req, err := http.NewRequestWithContext(ctx, http.MethodPost, authURL, bytes.NewReader([]byte(form.Encode()))) if err != nil {