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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 166 additions & 3 deletions commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package commands
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"

Expand All @@ -28,11 +31,37 @@ 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 and appApprovalSleep are replaced in tests.
var (
newAppApprovalService = model.NewAppApprovalService
appApprovalSleep = sleepContext
)

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)
Expand Down Expand Up @@ -73,9 +102,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
Expand Down Expand Up @@ -146,6 +179,136 @@ 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, appApprovalSleep)
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) {
// 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 {
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) && !retryableAppApprovalStatus(apiErr.StatusCode) {
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)
}
}
}

// 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():
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{
Expand Down
Loading
Loading