Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions cmd/entire/cli/recap.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"golang.org/x/term"

"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/cmd/entire/cli/auth"
"github.com/entireio/cli/cmd/entire/cli/gitremote"
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/paths"
Expand Down Expand Up @@ -122,9 +123,17 @@ func runRecap(ctx context.Context, w, errW io.Writer, f *recapFlags) error {
if err != nil {
return err
}
client, err := NewAuthenticatedAPIClient(f.insecureHTTP)
client, err := newRecapClient(f.insecureHTTP)
if err != nil {
fmt.Fprintln(errW, "Sign in with `entire login` to use `entire recap`.")
var keyringErr *keyringReadError
switch {
case errors.Is(err, api.ErrInsecureHTTP):
fmt.Fprintf(errW, "ENTIRE_API_BASE_URL is set to an insecure http:// URL (%s). Use https:// for production, or pass --insecure-http-auth for local dev.\n", api.BaseURL())
case errors.As(err, &keyringErr):
fmt.Fprintf(errW, "Could not read your auth token from the system keyring: %v. Running `entire login` may not help — the keyring may be locked or inaccessible. Check your OS keychain settings.\n", keyringErr.Cause)
default:
return err
}
return NewSilentError(err)
}
rangeKey := f.rangeKey()
Expand Down Expand Up @@ -154,6 +163,34 @@ func runRecap(ctx context.Context, w, errW io.Writer, f *recapFlags) error {
return nil
}

// keyringReadError marks a failure to read the auth token from the system
// keyring (locked, permission denied, etc.) — distinct from "no token saved",
// which keyring.ErrNotFound resolves to (token=="", err==nil) upstream.
type keyringReadError struct{ Cause error }

func (e *keyringReadError) Error() string {
return "read auth token from keyring: " + e.Cause.Error()
}
func (e *keyringReadError) Unwrap() error { return e.Cause }

// newRecapClient does not gate on a missing token; FetchMeRecap surfaces 401s
// via recapLoadErrorMessage so flag effects (--week, --agent, ...) and the
// real auth error are not collapsed into one "sign in" hint. A keyring read
// failure is surfaced as *keyringReadError so the caller can show a targeted
// message instead of misattributing it to a missing login.
func newRecapClient(insecureHTTP bool) (*api.Client, error) {
token, err := auth.LookupCurrentToken()
if err != nil {
return nil, &keyringReadError{Cause: err}
}
if token != "" && !insecureHTTP {
if err := api.RequireSecureURL(api.BaseURL()); err != nil {
return nil, fmt.Errorf("base URL check: %w", err)
}
}
return api.NewClient(token), nil
}

func handleRecapFetchError(w io.Writer, err error) error {
if shouldShowRecapLoadErrorMessage(err) {
fmt.Fprintln(w, recapLoadErrorMessage(err))
Expand Down
13 changes: 13 additions & 0 deletions cmd/entire/cli/recap_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,25 @@ func recapLoadErrorMessage(err error) string {
return err.Error()
}
}
if host, ok := dnsNotFoundHost(err); ok {
return fmt.Sprintf("Could not resolve API host %q (DNS lookup failed). Check ENTIRE_API_BASE_URL — the host may be misspelled or the env var may be pointing at a non-existent server. Details: %v", host, err)
}
if isRecapNetworkError(err) {
return fmt.Sprintf("Could not reach entire.io. Check your internet connection and ENTIRE_API_BASE_URL if you use a custom API host. Details: %v", err)
}
return err.Error()
}

// dnsNotFoundHost reports an NXDOMAIN-style failure, distinguishing "host
// doesn't exist" from generic connectivity loss.
func dnsNotFoundHost(err error) (string, bool) {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return dnsErr.Name, true
}
return "", false
}

func recapErrorDetail(err *api.HTTPError) string {
if strings.TrimSpace(err.Message) != "" {
return fmt.Sprintf("HTTP %d: %s", err.StatusCode, err.Message)
Expand Down
40 changes: 40 additions & 0 deletions cmd/entire/cli/recap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,27 @@ func TestRecapFlags_ColorEnabled(t *testing.T) {
}
}

func TestKeyringReadError_PreservesCauseAndMatchesAs(t *testing.T) {
t.Parallel()

cause := errors.New("keychain locked")
err := error(&keyringReadError{Cause: cause})

if !errors.Is(err, cause) {
t.Fatalf("errors.Is should match wrapped cause; got false for %v", err)
}
var keyringErr *keyringReadError
if !errors.As(err, &keyringErr) {
t.Fatalf("errors.As should extract *keyringReadError; got false for %v", err)
}
if !errors.Is(keyringErr.Cause, cause) {
t.Fatalf("Cause = %v, want %v", keyringErr.Cause, cause)
}
if !strings.Contains(err.Error(), "keychain locked") {
t.Fatalf("Error() should include cause text; got %q", err.Error())
}
}

func TestRunRecap_PrerequisiteErrorsUseErrorWriter(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
Expand Down Expand Up @@ -314,6 +335,25 @@ func TestRecapLoadErrorMessage_NetworkError(t *testing.T) {
}
}

func TestRecapLoadErrorMessage_DNSNotFound(t *testing.T) {
t.Parallel()

nxdomain := &net.DNSError{Name: "no-token-here.example.com", Err: "no such host", IsNotFound: true}
got := recapLoadErrorMessage(fmt.Errorf("me/recap get: %w", nxdomain))
if strings.Contains(got, "Check your internet connection") {
t.Fatalf("NXDOMAIN should not blame internet connection:\n%s", got)
}
for _, want := range []string{
"Could not resolve API host",
"no-token-here.example.com",
"ENTIRE_API_BASE_URL",
} {
if !strings.Contains(got, want) {
t.Fatalf("message missing %q:\n%s", want, got)
}
}
}

func TestRecapLoadErrorMessage_ContextCancellation(t *testing.T) {
t.Parallel()

Expand Down
Loading