From 396a878c3c69b4be8b8ccc144181357feb76b2c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:59:29 +0000 Subject: [PATCH 1/3] feat(cli): sign in by approving on your iPhone with `auth --email` `shelltime auth --email you@example.com` (also on `init`) asks the server to push a sign-in request to the ShellTime iOS app of that account, prints the 2-digit code the iPhone shows, and polls until the request is approved, denied or expired. On approval it saves the new token like the browser flow does. It needs no browser, so an agent can run it and relay the code to the user. Output is plain lines (the spinner only shows on a terminal). Network and server errors are retried until the request expires; a request the API refuses, a denial or an expiry print why in red and exit 1 (ErrAuthNotApproved). `--token` and `--email` together are an error. Plain `shelltime auth` keeps the browser handshake untouched, which the Mac app's setup relies on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013cY2Zv1kE3eQV5b9c5sQJA --- README.md | 4 +- cmd/cli/main.go | 3 +- commands/auth.go | 154 +++++++++++++++++++++++++- commands/auth_app_approval_test.go | 171 +++++++++++++++++++++++++++++ commands/init.go | 1 + model/app_approval.go | 148 +++++++++++++++++++++++++ model/app_approval_test.go | 90 +++++++++++++++ 7 files changed, 565 insertions(+), 6 deletions(-) create mode 100644 commands/auth_app_approval_test.go create mode 100644 model/app_approval.go create mode 100644 model/app_approval_test.go diff --git a/README.md b/README.md index 2a0321c..77be964 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The fastest way to get set up is a single command: shelltime init ``` -`shelltime init` authenticates the CLI (in the browser, or pass `--token`), installs the bash, zsh and fish hooks and the daemon, and tries to wire up Claude Code and Codex OTEL integration for you. +`shelltime init` authenticates the CLI (in the browser, with `--email` to approve on your iPhone in the ShellTime iOS app, or with `--token`), installs the bash, zsh and fish hooks and the daemon, and tries to wire up Claude Code and Codex OTEL integration for you. Prefer to do it step by step? @@ -73,7 +73,7 @@ shelltime codex install | Command | Description | |---------|-------------| | `shelltime init` | Bootstrap auth, hooks, daemon, and AI-code integrations | -| `shelltime auth` | Authenticate with `shelltime.xyz` (`--token` skips the browser flow) | +| `shelltime auth` | Authenticate with `shelltime.xyz` (`--token` skips the browser flow; `--email you@example.com` asks the ShellTime iOS app to approve instead, with no browser, so agents can run it) | | `shelltime update` | Download and install the latest release in place (`--check`, `--force`, `--skip-daemon-reinstall`) | | `shelltime doctor` | Diagnose setup problems and show how to fix each one. `--fix` applies the safe fixes after asking (`--yes` skips the prompt), `--offline` skips network checks, `--format json` prints a machine-readable report. Exits non-zero when a check fails | | `shelltime web` | Open the ShellTime dashboard in a browser | diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 311ca4a..141b106 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -125,7 +125,8 @@ func run() int { slog.Error("CLI error", slog.Any("err", err)) } commands.CloseLogger() - if doctorFailed { + // A sign-in through the iOS app that didn't complete already printed why. + if doctorFailed || errors.Is(err, commands.ErrAuthNotApproved) { return 1 } return 0 diff --git a/commands/auth.go b/commands/auth.go index f3962cb..7070abf 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -3,8 +3,11 @@ package commands import ( "context" "encoding/json" + "errors" "fmt" + "io" "log/slog" + "net/http" "os" "time" @@ -28,11 +31,34 @@ var AuthCommand *cli.Command = &cli.Command{ Usage: "Authentication token", Required: false, }, + emailFlag, }, Action: commandAuth, } +// emailFlag signs in by approving a request in the ShellTime iOS app instead +// of the browser. It needs no browser, so agents can run it. +var emailFlag = &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Sign in by approving a request on the iPhone of the account with this email (ShellTime iOS app)", + Required: false, +} + +// ErrAuthNotApproved means signing in through the iOS app did not complete: +// the request was denied, expired or refused. The CLI exits non-zero. +var ErrAuthNotApproved = errors.New("sign-in was not approved") + +// newAppApprovalService is replaced in tests. +var newAppApprovalService = model.NewAppApprovalService + func commandAuth(c *cli.Context) error { + newToken := c.String("token") + email := c.String("email") + if newToken != "" && email != "" { + return errors.New("use either --token or --email, not both") + } + ctx, span := commandTracer.Start(c.Context, "auth") defer span.End() configDir := os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER) @@ -73,9 +99,13 @@ func commandAuth(c *cli.Context) error { config = existingConfig } - newToken := c.String("token") - - if newToken == "" { + if newToken == "" && email != "" { + nt, err := ApplyTokenByAppApproval(ctx, config, email) + if err != nil { + return err + } + newToken = nt + } else if newToken == "" { nt, err := ApplyTokenByHandshake(ctx, config) if err != nil { return err @@ -146,6 +176,124 @@ func ApplyTokenByHandshake(_ctx context.Context, config model.ShellTimeConfig) ( } } +// ApplyTokenByAppApproval pushes a sign-in request to the ShellTime iOS app of +// the account with this email and waits for the user to approve it. Output is +// plain lines, so an agent driving the CLI can relay the code to the user. +func ApplyTokenByAppApproval(_ctx context.Context, config model.ShellTimeConfig, email string) (string, error) { + ctx, span := commandTracer.Start(_ctx, "appApproval", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + svc := newAppApprovalService(config) + ticket, err := svc.Request(ctx, email) + if err != nil { + var apiErr *model.AppApprovalError + if errors.As(err, &apiErr) { + color.Red.Println(" ❌ " + apiErr.Message) + if apiErr.StatusCode == http.StatusNotFound { + color.Yellow.Println("Run `shelltime auth` without --email to sign in with your browser instead.") + } + } else { + color.Red.Println(fmt.Sprintf(" ❌ Failed to send the sign-in request: %v", err)) + } + return "", fmt.Errorf("%w: %w", ErrAuthNotApproved, err) + } + + printAppApprovalIntro(os.Stdout, ticket) + stopIndicator := startWaitingIndicator() + token, err := waitForAppApproval(ctx, svc, ticket, sleepContext) + stopIndicator() + if err != nil { + color.Red.Println(" ❌ " + err.Error()) + return "", err + } + + color.Green.Println(" ✅ You are ready to go!") + return token, nil +} + +func printAppApprovalIntro(out io.Writer, ticket model.AppApprovalTicket) { + fmt.Fprintln(out, "Sent a sign-in request to the ShellTime app on your iPhone.") + fmt.Fprintf(out, "Code: %s. Check that your iPhone shows the same code, then approve it.\n", ticket.MatchCode) + fmt.Fprintf(out, "Waiting for approval (expires in %s)...\n", time.Duration(ticket.ExpiresIn)*time.Second) +} + +// appApprovalPollGrace keeps polling a little past the expiry in case the +// server's clock is ahead; the server answers "expired" on its own anyway. +const appApprovalPollGrace = 30 * time.Second + +// waitForAppApproval polls until the request is approved, denied or expired. +// Network and server errors are retried until the request expires; a request +// the API refuses ends the wait. +func waitForAppApproval( + ctx context.Context, + svc model.AppApprovalService, + ticket model.AppApprovalTicket, + sleep func(context.Context, time.Duration) error, +) (string, error) { + interval := time.Duration(max(ticket.Interval, 1)) * time.Second + giveUpAt := time.Now().Add(time.Duration(ticket.ExpiresIn)*time.Second + appApprovalPollGrace) + + for { + if err := sleep(ctx, interval); err != nil { + return "", err + } + + result, err := svc.Poll(ctx, ticket.RequestID, ticket.PollToken) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + var apiErr *model.AppApprovalError + if errors.As(err, &apiErr) && apiErr.StatusCode < http.StatusInternalServerError && apiErr.StatusCode != http.StatusTooManyRequests { + return "", fmt.Errorf("%w: %s", ErrAuthNotApproved, apiErr.Message) + } + slog.Warn("app approval poll failed, retrying", slog.Any("err", err)) + } else { + switch result.Status { + case model.AppApprovalApproved: + if result.OpenToken == nil || result.OpenToken.Token == "" { + return "", fmt.Errorf("%w: the server approved the request but sent no token", ErrAuthNotApproved) + } + return result.OpenToken.Token, nil + case model.AppApprovalDenied: + return "", fmt.Errorf("%w: the request was denied on your iPhone", ErrAuthNotApproved) + case model.AppApprovalExpired: + return "", fmt.Errorf("%w: the request expired before it was approved", ErrAuthNotApproved) + } + } + + if time.Now().After(giveUpAt) { + return "", fmt.Errorf("%w: the request expired before it was approved", ErrAuthNotApproved) + } + } +} + +func sleepContext(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +// startWaitingIndicator shows a spinner when a person is watching the +// terminal; piped output (an agent, a script) stays plain. It returns the +// function that stops it. +func startWaitingIndicator() func() { + info, err := os.Stdout.Stat() + if err != nil || info.Mode()&os.ModeCharDevice == 0 { + return func() {} + } + l := stloader.NewLoader(stloader.LoaderConfig{ + Text: "Waiting for approval on your iPhone...", + EnableShining: true, + BaseColor: stloader.RGB{R: 100, G: 180, B: 255}, + }) + l.Start() + return l.Stop +} + // generateSchemaFile generates the JSON schema file for config autocompletion func generateSchemaFile(path string) error { reflector := &jsonschema.Reflector{ diff --git a/commands/auth_app_approval_test.go b/commands/auth_app_approval_test.go new file mode 100644 index 0000000..ddb52af --- /dev/null +++ b/commands/auth_app_approval_test.go @@ -0,0 +1,171 @@ +package commands + +import ( + "bytes" + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +type pollStep struct { + result model.AppApprovalPollResult + err error +} + +// fakeAppApproval answers polls from a script, repeating the last step. +type fakeAppApproval struct { + ticket model.AppApprovalTicket + err error + steps []pollStep + polls int +} + +func (f *fakeAppApproval) Request(context.Context, string) (model.AppApprovalTicket, error) { + return f.ticket, f.err +} + +func (f *fakeAppApproval) Poll(_ context.Context, requestID, pollToken string) (model.AppApprovalPollResult, error) { + if requestID != f.ticket.RequestID || pollToken != f.ticket.PollToken { + return model.AppApprovalPollResult{}, errors.New("unexpected request") + } + step := f.steps[min(f.polls, len(f.steps)-1)] + f.polls++ + return step.result, step.err +} + +func approvedWith(token string) pollStep { + result := model.AppApprovalPollResult{Status: model.AppApprovalApproved} + result.OpenToken = &struct { + ID int `json:"id"` + Token string `json:"token"` + }{ID: 1, Token: token} + return pollStep{result: result} +} + +func statusStep(status model.AppApprovalStatus) pollStep { + return pollStep{result: model.AppApprovalPollResult{Status: status}} +} + +var testTicket = model.AppApprovalTicket{RequestID: "rid", PollToken: "ptok", MatchCode: "42", ExpiresIn: 300, Interval: 3} + +// noSleep records the waits instead of sleeping. +func noSleep(waits *[]time.Duration) func(context.Context, time.Duration) error { + return func(ctx context.Context, d time.Duration) error { + *waits = append(*waits, d) + return ctx.Err() + } +} + +func TestWaitForAppApprovalApproved(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + statusStep(model.AppApprovalPending), + statusStep(model.AppApprovalPending), + approvedWith("st_v1_token"), + }} + var waits []time.Duration + + token, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) + assert.Equal(t, 3, svc.polls) + assert.Equal(t, []time.Duration{3 * time.Second, 3 * time.Second, 3 * time.Second}, waits, "uses the server's interval") +} + +func TestWaitForAppApprovalDenied(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalDenied)}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "denied") +} + +func TestWaitForAppApprovalExpired(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalExpired)}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "expired") +} + +func TestWaitForAppApprovalRetriesServerErrors(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + {err: errors.New("connection reset")}, + {err: &model.AppApprovalError{StatusCode: http.StatusBadGateway, Message: "Bad Gateway"}}, + approvedWith("st_v1_token"), + }} + var waits []time.Duration + + token, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) +} + +func TestWaitForAppApprovalStopsOnRefusal(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + {err: &model.AppApprovalError{StatusCode: http.StatusBadRequest, Message: "Invalid sign-in request"}}, + }} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "Invalid sign-in request") + assert.Equal(t, 1, svc.polls) +} + +func TestWaitForAppApprovalCancelled(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalPending)}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var waits []time.Duration + + _, err := waitForAppApproval(ctx, svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, context.Canceled) + assert.Equal(t, 0, svc.polls) +} + +func TestWaitForAppApprovalGivesUpAfterExpiry(t *testing.T) { + ticket := testTicket + ticket.ExpiresIn = -int(appApprovalPollGrace/time.Second) - 1 + svc := &fakeAppApproval{ticket: ticket, steps: []pollStep{statusStep(model.AppApprovalPending)}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, ticket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Equal(t, 1, svc.polls) +} + +func TestPrintAppApprovalIntro(t *testing.T) { + var out bytes.Buffer + printAppApprovalIntro(&out, testTicket) + + assert.Contains(t, out.String(), "ShellTime app on your iPhone") + assert.Contains(t, out.String(), "Code: 42") + assert.Contains(t, out.String(), "expires in 5m0s") +} + +func TestApplyTokenByAppApprovalRefused(t *testing.T) { + original := newAppApprovalService + t.Cleanup(func() { newAppApprovalService = original }) + newAppApprovalService = func(model.ShellTimeConfig) model.AppApprovalService { + return &fakeAppApproval{err: &model.AppApprovalError{StatusCode: http.StatusNotFound, Message: "no iOS app"}} + } + + _, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + assert.ErrorIs(t, err, ErrAuthNotApproved) +} + +func TestAuthRejectsTokenWithEmail(t *testing.T) { + app := &cli.App{Commands: []*cli.Command{AuthCommand}} + err := app.Run([]string{"shelltime", "auth", "--token", "abc", "--email", "me@example.com"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "either --token or --email") +} diff --git a/commands/init.go b/commands/init.go index dc57169..6842d34 100644 --- a/commands/init.go +++ b/commands/init.go @@ -15,6 +15,7 @@ var InitCommand *cli.Command = &cli.Command{ Usage: "Authentication token", Required: false, }, + emailFlag, }, Action: commandInit, } diff --git a/model/app_approval.go b/model/app_approval.go new file mode 100644 index 0000000..4eb019d --- /dev/null +++ b/model/app_approval.go @@ -0,0 +1,148 @@ +package model + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "time" +) + +// AppApprovalTicket is a sign-in request waiting for approval in the ShellTime +// iOS app. +type AppApprovalTicket struct { + RequestID string `json:"requestId"` + // PollToken authenticates our polls; only this CLI has it + PollToken string `json:"pollToken"` + // MatchCode is also shown in the iOS app, so the user can check the + // request they approve is this one + MatchCode string `json:"matchCode"` + ExpiresIn int `json:"expiresIn"` + // Interval is the number of seconds between polls + Interval int `json:"interval"` +} + +type AppApprovalStatus string + +const ( + AppApprovalPending AppApprovalStatus = "pending" + AppApprovalApproved AppApprovalStatus = "approved" + AppApprovalDenied AppApprovalStatus = "denied" + AppApprovalExpired AppApprovalStatus = "expired" +) + +type AppApprovalPollResult struct { + Status AppApprovalStatus `json:"status"` + ExpiresIn int `json:"expiresIn,omitempty"` + // OpenToken is set on the one poll that collects an approved request + OpenToken *struct { + ID int `json:"id"` + Token string `json:"token"` + } `json:"openToken,omitempty"` +} + +// AppApprovalError is the API refusing a request, with its message for the user. +type AppApprovalError struct { + StatusCode int + Message string +} + +func (e *AppApprovalError) Error() string { + return fmt.Sprintf("app approval: HTTP %d: %s", e.StatusCode, e.Message) +} + +// AppApprovalService signs the CLI in by having the user approve a push in the +// ShellTime iOS app. +type AppApprovalService interface { + // Request pushes a sign-in request to the iOS app of the account with this email. + Request(ctx context.Context, email string) (AppApprovalTicket, error) + // Poll reports the request's status; the first poll after approval carries the token. + Poll(ctx context.Context, requestID, pollToken string) (AppApprovalPollResult, error) +} + +type appApprovalService struct { + config ShellTimeConfig +} + +func NewAppApprovalService(config ShellTimeConfig) AppApprovalService { + return appApprovalService{config: config} +} + +type appApprovalRequestBody struct { + Email string `json:"email"` + Client string `json:"client"` + Hostname string `json:"hostname"` + OS string `json:"os"` + OSVersion string `json:"osVersion"` +} + +type appApprovalPollBody struct { + RequestID string `json:"requestId"` + PollToken string `json:"pollToken"` +} + +func (s appApprovalService) Request(ctx context.Context, email string) (AppApprovalTicket, error) { + sysInfo, err := GetOSAndVersion() + if err != nil { + slog.Error("failed to get OS version", slog.Any("err", err)) + sysInfo = &SysInfo{Os: "unknown", Version: "unknown"} + } + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + var ticket AppApprovalTicket + err = s.post(ctx, "/request", appApprovalRequestBody{ + Email: email, + Client: "cli", + Hostname: hostname, + OS: sysInfo.Os, + OSVersion: sysInfo.Version, + }, &ticket) + return ticket, err +} + +func (s appApprovalService) Poll(ctx context.Context, requestID, pollToken string) (AppApprovalPollResult, error) { + var result AppApprovalPollResult + err := s.post(ctx, "/poll", appApprovalPollBody{RequestID: requestID, PollToken: pollToken}, &result) + return result, err +} + +// post sends an unauthenticated request: the CLI has no token yet. +func (s appApprovalService) post(ctx context.Context, path string, payload, result any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.APIEndpoint+"/api/v1/auth/app-approval"+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", fmt.Sprintf("shelltimeCLI@%s", commitID)) + + resp, err := NewHTTPClient(30 * time.Second).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + buf, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + message := http.StatusText(resp.StatusCode) + var errResp errorResponse + if json.Unmarshal(buf, &errResp) == nil && errResp.ErrorMessage != "" { + message = errResp.ErrorMessage + } + return &AppApprovalError{StatusCode: resp.StatusCode, Message: message} + } + return json.Unmarshal(buf, result) +} diff --git a/model/app_approval_test.go b/model/app_approval_test.go new file mode 100644 index 0000000..3e9eb19 --- /dev/null +++ b/model/app_approval_test.go @@ -0,0 +1,90 @@ +package model + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppApprovalRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/auth/app-approval/request", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Contains(t, r.Header.Get("User-Agent"), "shelltimeCLI@") + assert.Empty(t, r.Header.Get("Authorization")) + + var payload appApprovalRequestBody + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "me@example.com", payload.Email) + assert.Equal(t, "cli", payload.Client) + assert.NotEmpty(t, payload.Hostname) + assert.NotEmpty(t, payload.OS) + + w.Write([]byte(`{"requestId":"rid","pollToken":"ptok","matchCode":"42","expiresIn":300,"interval":2}`)) + })) + defer server.Close() + + ticket, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Request(context.Background(), "me@example.com") + require.NoError(t, err) + assert.Equal(t, AppApprovalTicket{RequestID: "rid", PollToken: "ptok", MatchCode: "42", ExpiresIn: 300, Interval: 2}, ticket) +} + +func TestAppApprovalRequestRefused(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"code":404,"error":"This account can't approve sign-ins in the ShellTime iOS app."}`)) + })) + defer server.Close() + + _, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Request(context.Background(), "me@example.com") + var apiErr *AppApprovalError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusNotFound, apiErr.StatusCode) + assert.Equal(t, "This account can't approve sign-ins in the ShellTime iOS app.", apiErr.Message) +} + +func TestAppApprovalErrorWithoutEnvelope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`bad gateway`)) + })) + defer server.Close() + + _, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Poll(context.Background(), "rid", "ptok") + var apiErr *AppApprovalError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusBadGateway, apiErr.StatusCode) + assert.Equal(t, "Bad Gateway", apiErr.Message) +} + +func TestAppApprovalPoll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/auth/app-approval/poll", r.URL.Path) + var payload appApprovalPollBody + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, appApprovalPollBody{RequestID: "rid", PollToken: "ptok"}, payload) + + w.Write([]byte(`{"status":"approved","openToken":{"id":9,"token":"st_v1_abc"}}`)) + })) + defer server.Close() + + result, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Poll(context.Background(), "rid", "ptok") + require.NoError(t, err) + assert.Equal(t, AppApprovalApproved, result.Status) + require.NotNil(t, result.OpenToken) + assert.Equal(t, "st_v1_abc", result.OpenToken.Token) +} + +func TestAppApprovalUnreachable(t *testing.T) { + _, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: "http://127.0.0.1:1"}).Request(context.Background(), "me@example.com") + assert.Error(t, err) + var apiErr *AppApprovalError + assert.False(t, errors.As(err, &apiErr)) +} From 31fcf30a171068cb6b707f4799ecae74087a18be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:20:26 +0000 Subject: [PATCH 2/3] test(cli): cover `auth --email` end to end Codecov's patch check failed at 65.8% against its 80% target: the command path itself (ApplyTokenByAppApproval, the config write after an approval, the sleep and the indicator) had no tests. Make the poll sleep swappable like the service and cover the approved, denied and unreachable paths, `shelltime auth --email` writing the token to config.yaml in a temporary HOME, sleepContext and the piped indicator. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013cY2Zv1kE3eQV5b9c5sQJA --- commands/auth.go | 9 +++-- commands/auth_app_approval_test.go | 65 ++++++++++++++++++++++++++++++ model/app_approval_test.go | 1 + 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 7070abf..85ab405 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -49,8 +49,11 @@ var emailFlag = &cli.StringFlag{ // the request was denied, expired or refused. The CLI exits non-zero. var ErrAuthNotApproved = errors.New("sign-in was not approved") -// newAppApprovalService is replaced in tests. -var newAppApprovalService = model.NewAppApprovalService +// newAppApprovalService and appApprovalSleep are replaced in tests. +var ( + newAppApprovalService = model.NewAppApprovalService + appApprovalSleep = sleepContext +) func commandAuth(c *cli.Context) error { newToken := c.String("token") @@ -200,7 +203,7 @@ func ApplyTokenByAppApproval(_ctx context.Context, config model.ShellTimeConfig, printAppApprovalIntro(os.Stdout, ticket) stopIndicator := startWaitingIndicator() - token, err := waitForAppApproval(ctx, svc, ticket, sleepContext) + token, err := waitForAppApproval(ctx, svc, ticket, appApprovalSleep) stopIndicator() if err != nil { color.Red.Println(" ❌ " + err.Error()) diff --git a/commands/auth_app_approval_test.go b/commands/auth_app_approval_test.go index ddb52af..393ae1a 100644 --- a/commands/auth_app_approval_test.go +++ b/commands/auth_app_approval_test.go @@ -5,6 +5,8 @@ import ( "context" "errors" "net/http" + "os" + "path/filepath" "testing" "time" @@ -169,3 +171,66 @@ func TestAuthRejectsTokenWithEmail(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "either --token or --email") } + +// useFakeAppApproval swaps in svc and a sleep that returns at once. +func useFakeAppApproval(t *testing.T, svc *fakeAppApproval) { + originalService, originalSleep := newAppApprovalService, appApprovalSleep + t.Cleanup(func() { newAppApprovalService, appApprovalSleep = originalService, originalSleep }) + newAppApprovalService = func(model.ShellTimeConfig) model.AppApprovalService { return svc } + appApprovalSleep = func(ctx context.Context, _ time.Duration) error { return ctx.Err() } +} + +func TestApplyTokenByAppApprovalApproved(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + statusStep(model.AppApprovalPending), + approvedWith("st_v1_token"), + }} + useFakeAppApproval(t, svc) + + token, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) + assert.Equal(t, 2, svc.polls) +} + +func TestApplyTokenByAppApprovalDenied(t *testing.T) { + useFakeAppApproval(t, &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalDenied)}}) + + _, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + assert.ErrorIs(t, err, ErrAuthNotApproved) +} + +func TestApplyTokenByAppApprovalUnreachable(t *testing.T) { + useFakeAppApproval(t, &fakeAppApproval{err: errors.New("connection refused")}) + + _, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "connection refused") +} + +func TestAuthWithEmailSavesTheToken(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + useFakeAppApproval(t, &fakeAppApproval{ticket: testTicket, steps: []pollStep{approvedWith("st_v1_saved")}}) + + app := &cli.App{Commands: []*cli.Command{AuthCommand}} + require.NoError(t, app.Run([]string{"shelltime", "auth", "--email", "me@example.com"})) + + content, err := os.ReadFile(filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER, "config.yaml")) + require.NoError(t, err) + assert.Contains(t, string(content), "st_v1_saved") +} + +func TestSleepContext(t *testing.T) { + assert.NoError(t, sleepContext(context.Background(), time.Millisecond)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + assert.ErrorIs(t, sleepContext(ctx, time.Hour), context.Canceled) +} + +func TestWaitingIndicatorIsQuietWhenPiped(t *testing.T) { + // go test's stdout is not a terminal, so this must be a no-op + stop := startWaitingIndicator() + stop() +} diff --git a/model/app_approval_test.go b/model/app_approval_test.go index 3e9eb19..8cd4b1f 100644 --- a/model/app_approval_test.go +++ b/model/app_approval_test.go @@ -48,6 +48,7 @@ func TestAppApprovalRequestRefused(t *testing.T) { require.True(t, errors.As(err, &apiErr)) assert.Equal(t, http.StatusNotFound, apiErr.StatusCode) assert.Equal(t, "This account can't approve sign-ins in the ShellTime iOS app.", apiErr.Message) + assert.Contains(t, err.Error(), "HTTP 404") } func TestAppApprovalErrorWithoutEnvelope(t *testing.T) { From 168c2468c6caf002fbe8e2758d1ed3ebcf2b356d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:22:03 +0000 Subject: [PATCH 3/3] fix(cli): retry proxy timeouts and clamp the approval poll interval From the review: a 408 or 425 from a proxy in front of the API is transient like a 5xx or 429, so keep polling instead of ending the sign-in; and clamp the server's poll interval to 1-30 s so a bad value can't stall the first poll for an hour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013cY2Zv1kE3eQV5b9c5sQJA --- commands/auth.go | 16 ++++++++++++++-- commands/auth_app_approval_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 85ab405..b96a5a0 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -233,7 +233,8 @@ func waitForAppApproval( ticket model.AppApprovalTicket, sleep func(context.Context, time.Duration) error, ) (string, error) { - interval := time.Duration(max(ticket.Interval, 1)) * time.Second + // The server sends 2 s; keep a bad value from stalling or hammering it + interval := time.Duration(min(max(ticket.Interval, 1), 30)) * time.Second giveUpAt := time.Now().Add(time.Duration(ticket.ExpiresIn)*time.Second + appApprovalPollGrace) for { @@ -247,7 +248,7 @@ func waitForAppApproval( return "", ctx.Err() } var apiErr *model.AppApprovalError - if errors.As(err, &apiErr) && apiErr.StatusCode < http.StatusInternalServerError && apiErr.StatusCode != http.StatusTooManyRequests { + if errors.As(err, &apiErr) && !retryableAppApprovalStatus(apiErr.StatusCode) { return "", fmt.Errorf("%w: %s", ErrAuthNotApproved, apiErr.Message) } slog.Warn("app approval poll failed, retrying", slog.Any("err", err)) @@ -271,6 +272,17 @@ func waitForAppApproval( } } +// retryableAppApprovalStatus: a poll that failed with this status may succeed +// next time (a server error, rate limiting, or a proxy timing out). +func retryableAppApprovalStatus(status int) bool { + switch status { + case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests: + return true + default: + return status >= http.StatusInternalServerError + } +} + func sleepContext(ctx context.Context, d time.Duration) error { select { case <-ctx.Done(): diff --git a/commands/auth_app_approval_test.go b/commands/auth_app_approval_test.go index 393ae1a..b894d28 100644 --- a/commands/auth_app_approval_test.go +++ b/commands/auth_app_approval_test.go @@ -111,6 +111,30 @@ func TestWaitForAppApprovalRetriesServerErrors(t *testing.T) { assert.Equal(t, "st_v1_token", token) } +func TestWaitForAppApprovalRetriesProxyTimeouts(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + {err: &model.AppApprovalError{StatusCode: http.StatusRequestTimeout, Message: "Request Timeout"}}, + {err: &model.AppApprovalError{StatusCode: http.StatusTooManyRequests, Message: "slow down"}}, + approvedWith("st_v1_token"), + }} + var waits []time.Duration + + token, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) +} + +func TestWaitForAppApprovalClampsTheInterval(t *testing.T) { + ticket := testTicket + ticket.Interval = 3600 + svc := &fakeAppApproval{ticket: ticket, steps: []pollStep{approvedWith("st_v1_token")}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, ticket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, []time.Duration{30 * time.Second}, waits) +} + func TestWaitForAppApprovalStopsOnRefusal(t *testing.T) { svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ {err: &model.AppApprovalError{StatusCode: http.StatusBadRequest, Message: "Invalid sign-in request"}},