diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..755aa16 Binary files /dev/null and b/.DS_Store differ diff --git a/cmd/auth.go b/cmd/auth.go index a62f9a3..b004b4d 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -78,12 +78,23 @@ This is a local check — no network call and no refresh is attempted, even when the saved token is expired. Use 'auth refresh' (or any export subcommand) to actually refresh. +If LIFTOFF_REFRESH_TOKEN is set it wins over any saved token, and status +reports that instead. Its validity is unknown without a network call, so +exit 0 there means "a token was supplied", not "the token works". + Per the quantcli shared contract: https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`, RunE: func(cmd *cobra.Command, args []string) error { + // Headless mode has no saved token to inspect and no expiry to + // report until a refresh happens, so report the source and stop. + if auth.EnvRefreshToken() != "" { + fmt.Fprintln(cmd.OutOrStdout(), "using LIFTOFF_REFRESH_TOKEN (headless; no saved token consulted)") + return nil + } + store, err := auth.Load() if err != nil { - return fmt.Errorf("not logged in — run: liftoff-export auth login") + return fmt.Errorf("not logged in — run: liftoff-export auth login (or set LIFTOFF_REFRESH_TOKEN)") } exp := store.ExpiresAt.Local().Format(time.RFC3339) if time.Now().After(store.ExpiresAt) { diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..b7b3fb3 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +// With LIFTOFF_REFRESH_TOKEN set, 'auth status' reports the headless source +// and exits 0 without consulting the saved token file — that file may not +// exist at all in a container. (#55) +func TestAuthStatus_HeadlessEnvWins(t *testing.T) { + t.Setenv("LIFTOFF_REFRESH_TOKEN", "rt-from-env") + t.Setenv("HOME", t.TempDir()) // no auth.json anywhere + + var out bytes.Buffer + statusCmd.SetOut(&out) + t.Cleanup(func() { statusCmd.SetOut(nil) }) + + if err := statusCmd.RunE(statusCmd, nil); err != nil { + t.Fatalf("headless status should succeed with no saved token, got: %v", err) + } + if !strings.Contains(out.String(), "LIFTOFF_REFRESH_TOKEN") { + t.Errorf("status should name the token source, got: %q", out.String()) + } +} + +// Without the env var and without a saved token, status still fails and +// points at both recovery paths. +func TestAuthStatus_NoTokenFails(t *testing.T) { + t.Setenv("LIFTOFF_REFRESH_TOKEN", "") + t.Setenv("HOME", t.TempDir()) + + err := statusCmd.RunE(statusCmd, nil) + if err == nil { + t.Fatal("expected an error when no token is available") + } + if !strings.Contains(err.Error(), "LIFTOFF_REFRESH_TOKEN") { + t.Errorf("error should mention the headless option, got: %v", err) + } +} diff --git a/cmd/prime.go b/cmd/prime.go index 8bb1abb..3577762 100644 --- a/cmd/prime.go +++ b/cmd/prime.go @@ -47,6 +47,9 @@ GOTCHAS - Workout dates are LOCAL — 11pm workouts bucket on the day you logged them. - API hosts rotate; set LIFTOFF_API_BASE=https://vX-Y-Z.api.getgymbros.com if data calls fail with "server is deprecated". + - Headless: set LIFTOFF_REFRESH_TOKEN to skip 'auth login' entirely (no + token file is read or written). Required for Google Sign-In accounts, + which have no password to type. - Bodyweight is read off Post.bodyweight (the value you entered for that workout). No workout that day means no bodyweight that day. - 'workouts stats' bins exercises by name. Renaming an exercise in diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c28fa37..6fe0bcd 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -22,6 +22,18 @@ const ( const apiBaseEnvVar = "LIFTOFF_API_BASE" +// refreshTokenEnvVar is the headless auth path required by the quantcli +// contract (§5): a fresh container exports without an interactive login. +// It also covers accounts that have no password to type — Google Sign-In +// accounts can't use 'auth login' at all. (#55) +const refreshTokenEnvVar = "LIFTOFF_REFRESH_TOKEN" + +// EnvRefreshToken returns the refresh token supplied via the environment, +// or "" when unset. When set it takes precedence over the saved token file. +func EnvRefreshToken() string { + return strings.TrimSpace(os.Getenv(refreshTokenEnvVar)) +} + var ( resolveOnce sync.Once resolved string @@ -69,9 +81,21 @@ func configPath() string { // GetToken returns a valid access token, refreshing if needed. func GetToken() (string, error) { + // Headless mode: the environment supplies the refresh token, so there is + // no login step and no token file. Deliberately not persisted — writing + // would clobber the token file of whoever is logged in on this machine. + // Costs one refresh call per invocation, which is fine for a CLI. + if rt := EnvRefreshToken(); rt != "" { + store, err := refresh(rt) + if err != nil { + return "", fmt.Errorf("%s refresh failed: %w", refreshTokenEnvVar, err) + } + return store.AccessToken, nil + } + store, err := load() if err != nil { - return "", fmt.Errorf("not logged in — run: liftoff-export auth login") + return "", fmt.Errorf("not logged in — run: liftoff-export auth login (or set %s)", refreshTokenEnvVar) } if time.Now().After(store.ExpiresAt) { store, err = Refresh(store.RefreshToken) @@ -82,8 +106,19 @@ func GetToken() (string, error) { return store.AccessToken, nil } -// Refresh exchanges a refresh token for a new access token via user.refreshToken. +// Refresh exchanges a refresh token for a new access token and saves the +// result. Use refresh directly when the tokens must not touch disk. func Refresh(refreshToken string) (*TokenStore, error) { + store, err := refresh(refreshToken) + if err != nil { + return nil, err + } + return store, Save(store) +} + +// refresh exchanges a refresh token for a new access token via +// user.refreshToken, without persisting anything. +func refresh(refreshToken string) (*TokenStore, error) { // tRPC batch GET: /api/trpc/user.refreshToken?batch=1&input={"0":{"json":""}} input, _ := json.Marshal(map[string]any{ "0": map[string]any{"json": refreshToken}, @@ -135,12 +170,11 @@ func Refresh(refreshToken string) (*TokenStore, error) { expiresAt, _ := time.Parse(time.RFC3339Nano, batch[0].Result.Data.JSON.AccessTokenExpiresAt) - store := &TokenStore{ + return &TokenStore{ AccessToken: batch[0].Result.Data.JSON.AccessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt.Add(-5 * time.Minute), // refresh 5min early - } - return store, Save(store) + }, nil } // Logout removes the stored auth tokens.