From fd7f1fdab0a0a9403d316a14500d6e1c83846592 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 18 May 2026 23:22:44 -0400
Subject: [PATCH 1/3] feat: release-blocker bundle for v0.2.0 (#17 #18 #21 #16
#15 #23 #39 #40)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bundles eight ship-blocker fixes called out in the QA triage on main:
- **#39 session cache (major):** persist auth/cookies to
$XDG_CACHE_HOME/crono-export/session.json (mode 0600), retry-on-stale,
CRONOMETER_NO_CACHE escape hatch, new `auth logout` subcommand. Fixes
the rate-limit foot-gun where 5–6 back-to-back calls trip Cronometer's
throttle. Also de-duplicates the "login failed: login failed:" wrap.
- **#17 typed nutrition/notes JSON (breaking):** csvToJSON now returns
[]map[string]any with best-effort coercion (numeric → float64,
true/false → bool, empty → null). Drops the contract drift where two
of five subcommands forced jq `tonumber` on every column.
- **#15 + #23 validation before login:** Args=cobra.NoArgs + a PreRunE
that calls chosenFormat — bad --format / positional args now exit
fast without burning a Cronometer login attempt.
- **#16 + #18 + #21 contract-violation trio:** empty markdown is silent
on stdout (friendly note → stderr); --until alone uses 7d window
ending at --until (was 1-day); inverted --since/--until warns to
stderr and returns empty + exit 0 (was non-zero error).
- **#40 VitaminDIU cosmetic:** strippedSuffix table had "UI" instead of
the standard "IU" abbreviation; vitamin D markdown was rendering
without splitting the unit. Added TestStrippedSuffix to cover.
- **#19 closed as part-fixed/part-wontfix:** zone-as-UTC was already
fixed by the clean-room rewrite (#37); the wire CSV carries no
time-of-day to surface. Documented in prime GOTCHAS.
New tests cover: strippedSuffix (cmd), resolveDateRange (cronoclient),
coerceCSVValue + csvToJSON (cronoclient), session cache round-trip
(cronoclient).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.gitignore | 1 +
cmd/auth.go | 38 ++++++-
cmd/biometrics.go | 9 +-
cmd/exercises.go | 9 +-
cmd/format.go | 120 ++++++++++++++--------
cmd/format_test.go | 25 +++++
cmd/notes.go | 9 +-
cmd/nutrition.go | 9 +-
cmd/prime.go | 13 ++-
cmd/servings.go | 9 +-
internal/cronoapi/client.go | 36 +++++++
internal/cronoclient/client.go | 133 +++++++++++++++++++++----
internal/cronoclient/client_test.go | 49 +++++++++
internal/cronoclient/daterange.go | 14 ++-
internal/cronoclient/daterange_test.go | 73 ++++++++++++++
internal/cronoclient/session.go | 120 ++++++++++++++++++++++
internal/cronoclient/session_test.go | 66 ++++++++++++
17 files changed, 655 insertions(+), 78 deletions(-)
create mode 100644 cmd/format_test.go
create mode 100644 internal/cronoclient/client_test.go
create mode 100644 internal/cronoclient/daterange_test.go
create mode 100644 internal/cronoclient/session.go
create mode 100644 internal/cronoclient/session_test.go
diff --git a/.gitignore b/.gitignore
index 0a1ae84..9d696f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,4 @@ cronometer-capture/
tools/wirecapture/captures/
tools/wirecapture/wirecapture
*.unredacted.json
+.DS_Store
diff --git a/cmd/auth.go b/cmd/auth.go
index 7ccfc71..55d3c50 100644
--- a/cmd/auth.go
+++ b/cmd/auth.go
@@ -5,6 +5,8 @@ import (
"os"
"github.com/spf13/cobra"
+
+ "github.com/quantcli/crono-export-cli/internal/cronoclient"
)
var authCmd = &cobra.Command{
@@ -36,12 +38,46 @@ https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`,
case pass == "":
return fmt.Errorf("missing CRONOMETER_PASSWORD")
}
- fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (env-var auth, no token cache)\n", user)
+ cacheState := "no cache"
+ if os.Getenv("CRONOMETER_NO_CACHE") == "" {
+ if p := cronoclient.SessionCachePath(); p != "" {
+ if _, err := os.Stat(p); err == nil {
+ cacheState = "session cached"
+ } else {
+ cacheState = "no session cached"
+ }
+ }
+ } else {
+ cacheState = "cache disabled (CRONOMETER_NO_CACHE set)"
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (%s)\n", user, cacheState)
+ return nil
+ },
+}
+
+var authLogoutCmd = &cobra.Command{
+ Use: "logout",
+ Short: "Delete the cached Cronometer session, forcing a fresh login next call",
+ Long: `Remove the on-disk session cache at $XDG_CACHE_HOME/crono-export/session.json.
+Useful after rotating your password or when you suspect a stale session
+is causing failures.
+
+This is a local-only operation: it does NOT call Cronometer's logout
+endpoint (that would invalidate the cached cookies for a session we've
+already deleted). The next export call will perform a fresh login.`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ p, err := cronoclient.DeleteCachedSession()
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "session cache cleared (%s)\n", p)
return nil
},
}
func init() {
authCmd.AddCommand(authStatusCmd)
+ authCmd.AddCommand(authLogoutCmd)
rootCmd.AddCommand(authCmd)
}
diff --git a/cmd/biometrics.go b/cmd/biometrics.go
index cc5bf6b..c6abc05 100644
--- a/cmd/biometrics.go
+++ b/cmd/biometrics.go
@@ -7,13 +7,18 @@ import (
)
var biometricsCmd = &cobra.Command{
- Use: "biometrics",
- Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)",
+ Use: "biometrics",
+ Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)",
+ Args: cobra.NoArgs,
+ PreRunE: ValidateExportFlags,
RunE: func(cmd *cobra.Command, _ []string) error {
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
if err != nil {
return err
}
+ if rng.IsEmpty() {
+ return emit(cmd, kindBiometrics, emptyValueFor(kindBiometrics))
+ }
ctx := cmd.Context()
c, err := cronoclient.NewLoggedIn(ctx)
if err != nil {
diff --git a/cmd/exercises.go b/cmd/exercises.go
index 9013a7b..f9b4d8d 100644
--- a/cmd/exercises.go
+++ b/cmd/exercises.go
@@ -7,13 +7,18 @@ import (
)
var exercisesCmd = &cobra.Command{
- Use: "exercises",
- Short: "Export logged exercises (cardio, strength, custom activities)",
+ Use: "exercises",
+ Short: "Export logged exercises (cardio, strength, custom activities)",
+ Args: cobra.NoArgs,
+ PreRunE: ValidateExportFlags,
RunE: func(cmd *cobra.Command, _ []string) error {
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
if err != nil {
return err
}
+ if rng.IsEmpty() {
+ return emit(cmd, kindExercises, emptyValueFor(kindExercises))
+ }
ctx := cmd.Context()
c, err := cronoclient.NewLoggedIn(ctx)
if err != nil {
diff --git a/cmd/format.go b/cmd/format.go
index 4e8f04f..646b1ac 100644
--- a/cmd/format.go
+++ b/cmd/format.go
@@ -31,6 +31,16 @@ func AddFormatFlags(cmd *cobra.Command) {
"Output format: markdown (default, fitdown-style) or json")
}
+// ValidateExportFlags is a PreRunE that fails fast on bad --format or date
+// flags before any network call is made. Without this, a typo in --format
+// would burn a Cronometer login attempt against the rate limit.
+func ValidateExportFlags(cmd *cobra.Command, _ []string) error {
+ if _, err := chosenFormat(cmd); err != nil {
+ return err
+ }
+ return nil
+}
+
func chosenFormat(cmd *cobra.Command) (string, error) {
f, _ := cmd.Flags().GetString("format")
switch f {
@@ -70,10 +80,10 @@ func renderMarkdown(w io.Writer, kind recordKind, v any) error {
recs, _ := v.(cronoapi.ExerciseRecords)
return renderExercises(w, recs)
case kindNutrition:
- rows, _ := v.([]map[string]string)
+ rows, _ := v.([]map[string]any)
return renderNutrition(w, rows)
case kindNotes:
- rows, _ := v.([]map[string]string)
+ rows, _ := v.([]map[string]any)
return renderNotes(w, rows)
}
return fmt.Errorf("renderMarkdown: unknown kind %d", kind)
@@ -81,9 +91,29 @@ func renderMarkdown(w io.Writer, kind recordKind, v any) error {
// ---- shared helpers ---------------------------------------------------
-func emptyMsg(w io.Writer) error {
- _, err := fmt.Fprintln(w, "_(no records in window)_")
- return err
+// noteEmpty writes a friendly "no records" note to stderr so humans see it,
+// while keeping stdout clean per the contract's "data only on stdout" rule.
+// w is ignored — kept for the renderer signatures.
+func noteEmpty(_ io.Writer) error {
+ fmt.Fprintln(os.Stderr, "(no records in window)")
+ return nil
+}
+
+// emptyValueFor returns the typed empty value for a record kind. Used
+// when the caller knows there are no records (e.g. inverted date range)
+// and wants to emit them without making a network call.
+func emptyValueFor(kind recordKind) any {
+ switch kind {
+ case kindServings:
+ return cronoapi.ServingRecords{}
+ case kindBiometrics:
+ return cronoapi.BiometricRecords{}
+ case kindExercises:
+ return cronoapi.ExerciseRecords{}
+ case kindNutrition, kindNotes:
+ return []map[string]any{}
+ }
+ return nil
}
// fmtFloat trims trailing zeros so 1.95 → "1.95" and 100.000 → "100".
@@ -103,7 +133,7 @@ func strippedSuffix(field string) (name, unit string) {
{"Kcal", "kcal"},
{"Mg", "mg"},
{"Ug", "µg"},
- {"UI", "IU"},
+ {"IU", "IU"},
{"G", "g"},
} {
if strings.HasSuffix(field, suf.go_) && len(field) > len(suf.go_) {
@@ -117,7 +147,7 @@ func strippedSuffix(field string) (name, unit string) {
func renderServings(w io.Writer, recs cronoapi.ServingRecords) error {
if len(recs) == 0 {
- return emptyMsg(w)
+ return noteEmpty(w)
}
// Group by local calendar date.
byDate := map[string][]cronoapi.ServingRecord{}
@@ -194,7 +224,7 @@ func strDefault(s, fallback string) string {
func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {
if len(recs) == 0 {
- return emptyMsg(w)
+ return noteEmpty(w)
}
byDate := map[string][]cronoapi.BiometricRecord{}
for _, r := range recs {
@@ -226,7 +256,7 @@ func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {
func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {
if len(recs) == 0 {
- return emptyMsg(w)
+ return noteEmpty(w)
}
byDate := map[string][]cronoapi.ExerciseRecord{}
for _, r := range recs {
@@ -263,19 +293,19 @@ func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {
// ---- nutrition (daily totals, string-keyed CSV) ----------------------
-func renderNutrition(w io.Writer, rows []map[string]string) error {
+func renderNutrition(w io.Writer, rows []map[string]any) error {
if len(rows) == 0 {
- return emptyMsg(w)
+ return noteEmpty(w)
}
// Sort by Date asc.
sort.SliceStable(rows, func(i, j int) bool {
- return rows[i]["Date"] < rows[j]["Date"]
+ return cellString(rows[i]["Date"]) < cellString(rows[j]["Date"])
})
for di, row := range rows {
if di > 0 {
fmt.Fprintln(w)
}
- date := row["Date"]
+ date := cellString(row["Date"])
if date == "" {
date = "(unknown date)"
}
@@ -294,7 +324,7 @@ func renderNutrition(w io.Writer, rows []map[string]string) error {
if isZeroish(v) {
continue
}
- fmt.Fprintf(w, "- %s: %s\n", k, v)
+ fmt.Fprintf(w, "- %s: %s\n", k, cellString(v))
}
}
fmt.Fprintln(w)
@@ -302,34 +332,46 @@ func renderNutrition(w io.Writer, rows []map[string]string) error {
return nil
}
-// isZeroish reports whether a CSV value should be treated as "no data" and
-// hidden from the markdown output. Empty strings, "0", "0.0", "0.00", etc.
-// are zeroish; everything else (including "false", "true", arbitrary text)
-// is rendered.
-func isZeroish(s string) bool {
- if s == "" {
- return true
- }
- // Try numeric: if it parses to 0, it's zeroish.
- var f float64
- if _, err := fmt.Sscanf(s, "%f", &f); err == nil && f == 0 {
- // But only if the entire string was numeric.
- t := strings.TrimSpace(s)
- for _, r := range t {
- if !(r >= '0' && r <= '9') && r != '.' && r != '-' && r != '+' {
- return false
- }
+// cellString renders a coerced CSV cell back to a display string. Floats
+// drop trailing zeros so "1.5" stays "1.5" and "100" stays "100".
+func cellString(v any) string {
+ switch x := v.(type) {
+ case nil:
+ return ""
+ case string:
+ return x
+ case float64:
+ return fmtFloat(x)
+ case bool:
+ if x {
+ return "true"
}
+ return "false"
+ default:
+ return fmt.Sprintf("%v", x)
+ }
+}
+
+// isZeroish reports whether a coerced CSV value should be hidden from the
+// markdown output: nil, empty string, or numeric zero. "false" / "true" /
+// arbitrary text is rendered.
+func isZeroish(v any) bool {
+ switch x := v.(type) {
+ case nil:
return true
+ case string:
+ return x == ""
+ case float64:
+ return x == 0
}
return false
}
// ---- notes ------------------------------------------------------------
-func renderNotes(w io.Writer, rows []map[string]string) error {
+func renderNotes(w io.Writer, rows []map[string]any) error {
if len(rows) == 0 {
- return emptyMsg(w)
+ return noteEmpty(w)
}
dateKey := pickKey(rows[0], "Day", "Date")
noteKey := pickKey(rows[0], "Note", "Notes", "Comment")
@@ -339,31 +381,31 @@ func renderNotes(w io.Writer, rows []map[string]string) error {
if di > 0 {
fmt.Fprintln(w)
}
- date := row[dateKey]
+ date := cellString(row[dateKey])
if date == "" {
date = "(unknown date)"
}
header := "## " + date
- if t := row[timeKey]; t != "" {
+ if t := cellString(row[timeKey]); t != "" {
header += " " + t
}
fmt.Fprintln(w, header)
- if note := strings.TrimSpace(row[noteKey]); note != "" {
+ if note := strings.TrimSpace(cellString(row[noteKey])); note != "" {
fmt.Fprintln(w, note)
} else {
// Fall back to dumping all non-empty fields if we can't find a Note column.
for k, v := range row {
- if k == dateKey || k == timeKey || v == "" {
+ if k == dateKey || k == timeKey || isZeroish(v) {
continue
}
- fmt.Fprintf(w, "- %s: %s\n", k, v)
+ fmt.Fprintf(w, "- %s: %s\n", k, cellString(v))
}
}
}
return nil
}
-func pickKey(row map[string]string, candidates ...string) string {
+func pickKey(row map[string]any, candidates ...string) string {
for _, c := range candidates {
if _, ok := row[c]; ok {
return c
diff --git a/cmd/format_test.go b/cmd/format_test.go
new file mode 100644
index 0000000..31130df
--- /dev/null
+++ b/cmd/format_test.go
@@ -0,0 +1,25 @@
+package cmd
+
+import "testing"
+
+func TestStrippedSuffix(t *testing.T) {
+ cases := []struct {
+ field, wantName, wantUnit string
+ }{
+ {"EnergyKcal", "Energy", "kcal"},
+ {"CarbsG", "Carbs", "g"},
+ {"CalciumMg", "Calcium", "mg"},
+ {"VitaminAUg", "VitaminA", "µg"},
+ {"VitaminDIU", "VitaminD", "IU"},
+ {"PolyunsaturatedG", "Polyunsaturated", "g"},
+ {"Group", "Group", ""},
+ {"G", "G", ""},
+ }
+ for _, c := range cases {
+ gotName, gotUnit := strippedSuffix(c.field)
+ if gotName != c.wantName || gotUnit != c.wantUnit {
+ t.Errorf("strippedSuffix(%q) = (%q, %q), want (%q, %q)",
+ c.field, gotName, gotUnit, c.wantName, c.wantUnit)
+ }
+ }
+}
diff --git a/cmd/notes.go b/cmd/notes.go
index 734ac84..250436d 100644
--- a/cmd/notes.go
+++ b/cmd/notes.go
@@ -7,13 +7,18 @@ import (
)
var notesCmd = &cobra.Command{
- Use: "notes",
- Short: "Export user-entered notes",
+ Use: "notes",
+ Short: "Export user-entered notes",
+ Args: cobra.NoArgs,
+ PreRunE: ValidateExportFlags,
RunE: func(cmd *cobra.Command, _ []string) error {
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
if err != nil {
return err
}
+ if rng.IsEmpty() {
+ return emit(cmd, kindNotes, emptyValueFor(kindNotes))
+ }
ctx := cmd.Context()
c, err := cronoclient.NewLoggedIn(ctx)
if err != nil {
diff --git a/cmd/nutrition.go b/cmd/nutrition.go
index 68ecb9e..706689a 100644
--- a/cmd/nutrition.go
+++ b/cmd/nutrition.go
@@ -7,13 +7,18 @@ import (
)
var nutritionCmd = &cobra.Command{
- Use: "nutrition",
- Short: "Export daily total nutrition (one row per day, all macros + micros)",
+ Use: "nutrition",
+ Short: "Export daily total nutrition (one row per day, all macros + micros)",
+ Args: cobra.NoArgs,
+ PreRunE: ValidateExportFlags,
RunE: func(cmd *cobra.Command, _ []string) error {
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
if err != nil {
return err
}
+ if rng.IsEmpty() {
+ return emit(cmd, kindNutrition, emptyValueFor(kindNutrition))
+ }
ctx := cmd.Context()
c, err := cronoclient.NewLoggedIn(ctx)
if err != nil {
diff --git a/cmd/prime.go b/cmd/prime.go
index 502358c..f5fe7bb 100644
--- a/cmd/prime.go
+++ b/cmd/prime.go
@@ -18,10 +18,14 @@ I/O
stderr: errors. Exit 0 on success including empty results.
AUTH
- Env-var only — the CLI logs in on every run, no token cache.
+ Env-var credentials (always required):
CRONOMETER_USERNAME your Cronometer email
CRONOMETER_PASSWORD your Cronometer password
+ Session is cached at $XDG_CACHE_HOME/crono-export/session.json (mode 0600)
+ so consecutive calls reuse one login. Set CRONOMETER_NO_CACHE=1 to
+ disable. 'crono-export auth logout' clears the cache.
+
crono-export auth status Exit 0 if both vars set, 1 with "missing X".
DATE FLAGS (every subcommand)
@@ -32,7 +36,7 @@ DATE FLAGS (every subcommand)
SUBCOMMANDS
servings per-food log; one row per food eaten, full nutrient breakdown
- nutrition daily totals across all foods (string-valued JSON — see GOTCHAS)
+ nutrition daily totals across all foods (one row per day, all macros + micros)
biometrics weight, body fat, blood pressure, custom metrics
exercises logged cardio / strength / custom activities
notes user-entered notes per day
@@ -47,9 +51,8 @@ EXAMPLES
GOTCHAS
- 'today' is your LOCAL calendar day, not UTC.
- - 'nutrition' and 'notes' JSON values are STRINGS (raw CSV) — cast with
- 'jq tonumber' when doing math. 'servings', 'biometrics', 'exercises'
- are typed numbers.
+ - 'RecordedTime' is date-only (midnight in your local zone); Cronometer's
+ CSV exports don't carry meal-time, so all times sort as 00:00.
- Markdown drops zero-valued nutrients; use --format json for every column.
- 'servings' rows have a 'Day' field that is always null — use 'RecordedTime'.
`
diff --git a/cmd/servings.go b/cmd/servings.go
index 605e1e4..d20cb7a 100644
--- a/cmd/servings.go
+++ b/cmd/servings.go
@@ -7,13 +7,18 @@ import (
)
var servingsCmd = &cobra.Command{
- Use: "servings",
- Short: "Export logged food servings (one row per food eaten, full nutrient breakdown)",
+ Use: "servings",
+ Short: "Export logged food servings (one row per food eaten, full nutrient breakdown)",
+ Args: cobra.NoArgs,
+ PreRunE: ValidateExportFlags,
RunE: func(cmd *cobra.Command, _ []string) error {
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
if err != nil {
return err
}
+ if rng.IsEmpty() {
+ return emit(cmd, kindServings, emptyValueFor(kindServings))
+ }
ctx := cmd.Context()
c, err := cronoclient.NewLoggedIn(ctx)
if err != nil {
diff --git a/internal/cronoapi/client.go b/internal/cronoapi/client.go
index 43dc4c2..43f00d7 100644
--- a/internal/cronoapi/client.go
+++ b/internal/cronoapi/client.go
@@ -77,6 +77,42 @@ func (c *Client) AuthToken() string { return c.authToken }
// successful Login. Zero before Login or after Logout.
func (c *Client) UserID() int { return c.userID }
+// Session is a serialisable snapshot of an authenticated client.
+// Callers can persist this between invocations to skip the login
+// handshake (see Client.Snapshot / Client.Restore).
+type Session struct {
+ UserID int `json:"user_id"`
+ AuthToken string `json:"auth_token"`
+ Cookies []*http.Cookie `json:"cookies"`
+}
+
+// Snapshot returns the current session state — userID, GWT auth token,
+// and the cookies the jar holds for the configured base URL. Suitable
+// for serialisation to disk; restore on a fresh Client with Restore.
+func (c *Client) Snapshot() Session {
+ s := Session{UserID: c.userID, AuthToken: c.authToken}
+ if c.HTTPClient.Jar != nil {
+ if u, err := url.Parse(c.baseURL); err == nil {
+ s.Cookies = c.HTTPClient.Jar.Cookies(u)
+ }
+ }
+ return s
+}
+
+// Restore seeds the cookie jar and auth state from a previously
+// captured Session, skipping the login handshake. The caller is
+// responsible for verifying the restored session still works (e.g. by
+// retrying on failure and falling back to a fresh Login).
+func (c *Client) Restore(s Session) {
+ c.userID = s.UserID
+ c.authToken = s.AuthToken
+ if c.HTTPClient.Jar != nil && len(s.Cookies) > 0 {
+ if u, err := url.Parse(c.baseURL); err == nil {
+ c.HTTPClient.Jar.SetCookies(u, s.Cookies)
+ }
+ }
+}
+
// anticsrfRe extracts the hidden anti-CSRF token from the login HTML.
// WIRE_SHAPES.md §(1).
var anticsrfRe = regexp.MustCompile(`name="anticsrf"\s+value="([^"]+)"`)
diff --git a/internal/cronoclient/client.go b/internal/cronoclient/client.go
index 6a24444..683aa8e 100644
--- a/internal/cronoclient/client.go
+++ b/internal/cronoclient/client.go
@@ -5,6 +5,7 @@ import (
"encoding/csv"
"fmt"
"os"
+ "strconv"
"strings"
"time"
@@ -15,10 +16,25 @@ import (
// that return JSON-ready Go values.
type Client struct {
inner *cronoapi.Client
+ user string
+ pass string
+ // fresh is true if this client just performed a Login (i.e. the
+ // session is known-good). If false, the session came from the
+ // on-disk cache and may be stale; a single auto-retry on the first
+ // export call will fall back to a fresh login.
+ fresh bool
}
-// NewLoggedIn creates a client and logs in using CRONOMETER_USERNAME and
-// CRONOMETER_PASSWORD from the environment.
+// NewLoggedIn creates a client ready to call the export methods. It
+// prefers a cached session under $XDG_CACHE_HOME/crono-export/session.json
+// (mode 0600) and falls back to a fresh Cronometer login when no
+// cache exists or the cache is invalid for the current user. Set
+// CRONOMETER_NO_CACHE=1 to force a per-invocation login.
+//
+// CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set in either
+// case — even with a cache hit we hold the password so the wrapper
+// can transparently re-login if the cached session turns out to be
+// stale.
func NewLoggedIn(ctx context.Context) (*Client, error) {
user := os.Getenv("CRONOMETER_USERNAME")
pass := os.Getenv("CRONOMETER_PASSWORD")
@@ -26,20 +42,65 @@ func NewLoggedIn(ctx context.Context) (*Client, error) {
return nil, fmt.Errorf("CRONOMETER_USERNAME and CRONOMETER_PASSWORD must be set")
}
inner := cronoapi.NewClient(nil)
- if err := inner.Login(ctx, user, pass); err != nil {
- return nil, fmt.Errorf("login failed: %w", err)
+ c := &Client{inner: inner, user: user, pass: pass}
+
+ if cacheEnabled() {
+ if cached, err := loadCachedSession(user); err == nil && cached != nil {
+ inner.Restore(cached.Session)
+ return c, nil
+ }
+ }
+ if err := c.login(ctx); err != nil {
+ return nil, err
+ }
+ return c, nil
+}
+
+// login performs a fresh Cronometer login and, on success, persists
+// the session to the on-disk cache (unless caching is disabled).
+func (c *Client) login(ctx context.Context) error {
+ if err := c.inner.Login(ctx, c.user, c.pass); err != nil {
+ return fmt.Errorf("login: %w", err)
+ }
+ c.fresh = true
+ if cacheEnabled() {
+ _ = saveCachedSession(c.user, c.inner.Snapshot())
+ }
+ return nil
+}
+
+// callWithRetry runs fn; if fn fails AND the session was loaded from
+// cache (could be stale), it invalidates the cache, performs a fresh
+// login, and retries once. Returns the result of the (possibly
+// retried) call.
+func callWithRetry[T any](c *Client, ctx context.Context, fn func() (T, error)) (T, error) {
+ out, err := fn()
+ if err == nil || c.fresh {
+ return out, err
+ }
+ _, _ = DeleteCachedSession()
+ if rerr := c.login(ctx); rerr != nil {
+ return out, err // surface the original error, not the re-login one
}
- return &Client{inner: inner}, nil
+ return fn()
}
-// Logout best-effort, suitable for `defer`.
+// Logout best-effort, suitable for `defer`. When the cache is in
+// play we intentionally do NOT call the network logout — that would
+// invalidate the cached session for the next invocation. We only
+// tear down the server-side session when caching is disabled.
func (c *Client) Logout() {
+ if cacheEnabled() {
+ return
+ }
_ = c.inner.Logout(context.Background())
}
// Servings returns parsed serving records (one row per food item logged).
func (c *Client) Servings(ctx context.Context, rng DateRange) (any, error) {
- recs, err := c.inner.ExportServingsParsedWithLocation(ctx, rng.Start, rng.End, time.Local)
+ recs, err := callWithRetry(c, ctx, func() (cronoapi.ServingRecords, error) {
+ return c.inner.ExportServingsParsedWithLocation(ctx, rng.Start, rng.End, time.Local)
+ })
if err != nil {
return nil, fmt.Errorf("export servings: %w", err)
}
@@ -48,7 +109,9 @@ func (c *Client) Servings(ctx context.Context, rng DateRange) (any, error) {
// Exercises returns parsed exercise records.
func (c *Client) Exercises(ctx context.Context, rng DateRange) (any, error) {
- recs, err := c.inner.ExportExercisesParsedWithLocation(ctx, rng.Start, rng.End, time.Local)
+ recs, err := callWithRetry(c, ctx, func() (cronoapi.ExerciseRecords, error) {
+ return c.inner.ExportExercisesParsedWithLocation(ctx, rng.Start, rng.End, time.Local)
+ })
if err != nil {
return nil, fmt.Errorf("export exercises: %w", err)
}
@@ -57,18 +120,23 @@ func (c *Client) Exercises(ctx context.Context, rng DateRange) (any, error) {
// Biometrics returns parsed biometric records (weight, body fat, etc.).
func (c *Client) Biometrics(ctx context.Context, rng DateRange) (any, error) {
- recs, err := c.inner.ExportBiometricRecordsParsedWithLocation(ctx, rng.Start, rng.End, time.Local)
+ recs, err := callWithRetry(c, ctx, func() (cronoapi.BiometricRecords, error) {
+ return c.inner.ExportBiometricRecordsParsedWithLocation(ctx, rng.Start, rng.End, time.Local)
+ })
if err != nil {
return nil, fmt.Errorf("export biometrics: %w", err)
}
return recs, nil
}
-// Nutrition returns daily-totals nutrition rows. gocronometer does not
+// Nutrition returns daily-totals nutrition rows. cronoapi does not
// expose a typed parser for this endpoint, so we hand back a list of
-// string-keyed objects derived from the raw CSV header.
+// string-keyed objects derived from the raw CSV header. Values are
+// best-effort coerced (numeric → float64, true/false → bool, empty → null).
func (c *Client) Nutrition(ctx context.Context, rng DateRange) (any, error) {
- raw, err := c.inner.ExportDailyNutrition(ctx, rng.Start, rng.End)
+ raw, err := callWithRetry(c, ctx, func() (string, error) {
+ return c.inner.ExportDailyNutrition(ctx, rng.Start, rng.End)
+ })
if err != nil {
return nil, fmt.Errorf("export nutrition: %w", err)
}
@@ -79,9 +147,11 @@ func (c *Client) Nutrition(ctx context.Context, rng DateRange) (any, error) {
return rows, nil
}
-// Notes returns user-entered notes. Same string-keyed shape as Nutrition.
+// Notes returns user-entered notes. Same shape as Nutrition.
func (c *Client) Notes(ctx context.Context, rng DateRange) (any, error) {
- raw, err := c.inner.ExportNotes(ctx, rng.Start, rng.End)
+ raw, err := callWithRetry(c, ctx, func() (string, error) {
+ return c.inner.ExportNotes(ctx, rng.Start, rng.End)
+ })
if err != nil {
return nil, fmt.Errorf("export notes: %w", err)
}
@@ -92,7 +162,7 @@ func (c *Client) Notes(ctx context.Context, rng DateRange) (any, error) {
return rows, nil
}
-func csvToJSON(raw string) ([]map[string]string, error) {
+func csvToJSON(raw string) ([]map[string]any, error) {
r := csv.NewReader(strings.NewReader(raw))
r.FieldsPerRecord = -1
rows, err := r.ReadAll()
@@ -100,20 +170,41 @@ func csvToJSON(raw string) ([]map[string]string, error) {
return nil, err
}
if len(rows) == 0 {
- return []map[string]string{}, nil
+ return []map[string]any{}, nil
}
header := rows[0]
- out := make([]map[string]string, 0, len(rows)-1)
+ out := make([]map[string]any, 0, len(rows)-1)
for _, row := range rows[1:] {
- obj := make(map[string]string, len(header))
+ obj := make(map[string]any, len(header))
for i, col := range header {
+ var raw string
if i < len(row) {
- obj[col] = row[i]
- } else {
- obj[col] = ""
+ raw = row[i]
}
+ obj[col] = coerceCSVValue(raw)
}
out = append(out, obj)
}
return out, nil
}
+
+// coerceCSVValue best-effort-parses a CSV cell into a typed value: empty
+// → nil, numeric → float64, true/false → bool, otherwise the original
+// string. Keeps the JSON output of /nutrition and /notes consumable
+// with jq without forcing a `tonumber` cast on every column.
+func coerceCSVValue(s string) any {
+ trimmed := strings.TrimSpace(s)
+ if trimmed == "" {
+ return nil
+ }
+ if f, err := strconv.ParseFloat(trimmed, 64); err == nil {
+ return f
+ }
+ switch strings.ToLower(trimmed) {
+ case "true":
+ return true
+ case "false":
+ return false
+ }
+ return s
+}
diff --git a/internal/cronoclient/client_test.go b/internal/cronoclient/client_test.go
new file mode 100644
index 0000000..ed5a2a1
--- /dev/null
+++ b/internal/cronoclient/client_test.go
@@ -0,0 +1,49 @@
+package cronoclient
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestCoerceCSVValue(t *testing.T) {
+ cases := []struct {
+ in string
+ want any
+ }{
+ {"", nil},
+ {" ", nil},
+ {"1.5", 1.5},
+ {"100", 100.0},
+ {"-3", -3.0},
+ {"true", true},
+ {"FALSE", false},
+ {"Slept well", "Slept well"},
+ {"2026-05-04", "2026-05-04"},
+ }
+ for _, c := range cases {
+ got := coerceCSVValue(c.in)
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("coerceCSVValue(%q) = %v (%T), want %v (%T)",
+ c.in, got, got, c.want, c.want)
+ }
+ }
+}
+
+func TestCsvToJSON(t *testing.T) {
+ raw := "Date,Calories,Protein,Completed\n2026-05-04,1800,90,true\n2026-05-05,,75.5,false\n"
+ got, err := csvToJSON(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d rows, want 2", len(got))
+ }
+ row0 := got[0]
+ if row0["Date"] != "2026-05-04" || row0["Calories"] != 1800.0 || row0["Protein"] != 90.0 || row0["Completed"] != true {
+ t.Errorf("row0 = %+v", row0)
+ }
+ row1 := got[1]
+ if row1["Calories"] != nil || row1["Protein"] != 75.5 || row1["Completed"] != false {
+ t.Errorf("row1 = %+v", row1)
+ }
+}
diff --git a/internal/cronoclient/daterange.go b/internal/cronoclient/daterange.go
index 407d5c4..5e08248 100644
--- a/internal/cronoclient/daterange.go
+++ b/internal/cronoclient/daterange.go
@@ -6,6 +6,7 @@ package cronoclient
import (
"fmt"
+ "os"
"strings"
"time"
@@ -24,6 +25,14 @@ type DateRange struct {
End time.Time
}
+// IsEmpty reports whether the range covers no days. This happens when
+// the user supplies an inverted --since/--until pair; we treat it as
+// "no records" rather than an error so the contract's exit-0-on-empty
+// rule is honored.
+func (r DateRange) IsEmpty() bool {
+ return r.End.Before(r.Start)
+}
+
// AddDateRangeFlags binds --since and --until on cmd. Each subcommand calls
// this so they all share the same flag vocabulary, per the quantcli shared
// contract: https://github.com/quantcli/common/blob/main/CONTRACT.md#3-date-flags.
@@ -65,10 +74,11 @@ func resolveDateRange(sinceStr, untilStr string, ref time.Time) (DateRange, erro
until = today
}
if since.IsZero() {
- since = until
+ // Default since when only --until is given: 7 days ending at --until.
+ since = until.AddDate(0, 0, -6)
}
if until.Before(since) {
- return DateRange{}, fmt.Errorf("--until (%s) is before --since (%s)",
+ fmt.Fprintf(os.Stderr, "warning: --until (%s) is before --since (%s); returning empty result\n",
until.Format(dateLayout), since.Format(dateLayout))
}
return DateRange{Start: since, End: until}, nil
diff --git a/internal/cronoclient/daterange_test.go b/internal/cronoclient/daterange_test.go
new file mode 100644
index 0000000..24f474e
--- /dev/null
+++ b/internal/cronoclient/daterange_test.go
@@ -0,0 +1,73 @@
+package cronoclient
+
+import (
+ "testing"
+ "time"
+)
+
+func TestResolveDateRange(t *testing.T) {
+ ref := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
+ day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
+
+ cases := []struct {
+ name string
+ since, until string
+ wantStart, wantEnd time.Time
+ wantEmpty bool
+ }{
+ {
+ name: "both empty defaults to 7d ending today",
+ wantStart: day(2026, 5, 12),
+ wantEnd: day(2026, 5, 18),
+ },
+ {
+ name: "since only defaults until to today",
+ since: "2026-05-15",
+ wantStart: day(2026, 5, 15),
+ wantEnd: day(2026, 5, 18),
+ },
+ {
+ name: "until only defaults since to 7d before until (#18)",
+ until: "2026-05-15",
+ wantStart: day(2026, 5, 9),
+ wantEnd: day(2026, 5, 15),
+ },
+ {
+ name: "today / yesterday",
+ since: "yesterday",
+ until: "today",
+ wantStart: day(2026, 5, 17),
+ wantEnd: day(2026, 5, 18),
+ },
+ {
+ name: "relative 7d",
+ since: "7d",
+ wantStart: day(2026, 5, 11),
+ wantEnd: day(2026, 5, 18),
+ },
+ {
+ name: "inverted range returns empty (#21)",
+ since: "today",
+ until: "yesterday",
+ wantStart: day(2026, 5, 18),
+ wantEnd: day(2026, 5, 17),
+ wantEmpty: true,
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := resolveDateRange(c.since, c.until, ref)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !got.Start.Equal(c.wantStart) || !got.End.Equal(c.wantEnd) {
+ t.Errorf("got Start=%s End=%s, want Start=%s End=%s",
+ got.Start.Format(dateLayout), got.End.Format(dateLayout),
+ c.wantStart.Format(dateLayout), c.wantEnd.Format(dateLayout))
+ }
+ if got.IsEmpty() != c.wantEmpty {
+ t.Errorf("IsEmpty()=%v, want %v", got.IsEmpty(), c.wantEmpty)
+ }
+ })
+ }
+}
diff --git a/internal/cronoclient/session.go b/internal/cronoclient/session.go
new file mode 100644
index 0000000..4d29d47
--- /dev/null
+++ b/internal/cronoclient/session.go
@@ -0,0 +1,120 @@
+package cronoclient
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/quantcli/crono-export-cli/internal/cronoapi"
+)
+
+// cachedSession is the on-disk representation of a Cronometer login.
+// We key by username so flipping CRONOMETER_USERNAME invalidates the
+// cache automatically instead of replaying another user's session.
+type cachedSession struct {
+ Username string `json:"username"`
+ Session cronoapi.Session `json:"session"`
+ SavedAt time.Time `json:"saved_at"`
+ UserAgent string `json:"user_agent,omitempty"`
+}
+
+// sessionCachePath returns the path to the persisted session file,
+// rooted at the OS user cache dir (respects XDG_CACHE_HOME on Linux,
+// ~/Library/Caches on macOS, %LocalAppData% on Windows).
+func sessionCachePath() (string, error) {
+ base, err := os.UserCacheDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(base, "crono-export", "session.json"), nil
+}
+
+// cacheEnabled reports whether we should read/write the session cache.
+// Set CRONOMETER_NO_CACHE=1 (or any non-empty value) to force per-call
+// login.
+func cacheEnabled() bool {
+ return os.Getenv("CRONOMETER_NO_CACHE") == ""
+}
+
+// loadCachedSession returns the persisted session for user, or
+// (nil, nil) if no usable cache exists. Errors are only returned for
+// genuine I/O surprises — a missing file or a user mismatch is
+// silently treated as "no cache" so callers fall through to a fresh
+// login.
+func loadCachedSession(user string) (*cachedSession, error) {
+ p, err := sessionCachePath()
+ if err != nil {
+ return nil, nil
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var s cachedSession
+ if err := json.Unmarshal(data, &s); err != nil {
+ return nil, nil
+ }
+ if s.Username != user {
+ return nil, nil
+ }
+ if s.Session.AuthToken == "" {
+ return nil, nil
+ }
+ return &s, nil
+}
+
+// saveCachedSession writes snap to disk with mode 0600. Best-effort —
+// errors are returned for callers that care to log them but never block
+// the export.
+func saveCachedSession(user string, snap cronoapi.Session) error {
+ p, err := sessionCachePath()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
+ return err
+ }
+ data, err := json.Marshal(cachedSession{
+ Username: user,
+ Session: snap,
+ SavedAt: time.Now(),
+ })
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(p, data, 0o600)
+}
+
+// DeleteCachedSession removes the on-disk session file. Exported so
+// the `auth logout` subcommand can invoke it. Returns nil if the
+// cache didn't exist.
+func DeleteCachedSession() (string, error) {
+ p, err := sessionCachePath()
+ if err != nil {
+ return "", err
+ }
+ if err := os.Remove(p); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return p, nil
+ }
+ return p, fmt.Errorf("delete session cache: %w", err)
+ }
+ return p, nil
+}
+
+// SessionCachePath returns the cache location for display purposes
+// (e.g. by `auth status`). Empty string if the OS cache dir is
+// undiscoverable.
+func SessionCachePath() string {
+ p, err := sessionCachePath()
+ if err != nil {
+ return ""
+ }
+ return p
+}
diff --git a/internal/cronoclient/session_test.go b/internal/cronoclient/session_test.go
new file mode 100644
index 0000000..6d13305
--- /dev/null
+++ b/internal/cronoclient/session_test.go
@@ -0,0 +1,66 @@
+package cronoclient
+
+import (
+ "net/http"
+ "os"
+ "testing"
+
+ "github.com/quantcli/crono-export-cli/internal/cronoapi"
+)
+
+func TestSessionCacheRoundTrip(t *testing.T) {
+ // Redirect the cache to a temp dir so we don't clobber the real one.
+ tmp := t.TempDir()
+ t.Setenv("XDG_CACHE_HOME", tmp)
+ // macOS UserCacheDir ignores XDG_CACHE_HOME; on platforms where it
+ // reads HOME instead, also point HOME at the temp dir.
+ t.Setenv("HOME", tmp)
+
+ user := "alice@example.com"
+ snap := cronoapi.Session{
+ UserID: 42,
+ AuthToken: "0123456789abcdef0123456789abcdef",
+ Cookies: []*http.Cookie{
+ {Name: "JSESSIONID", Value: "abc", Path: "/"},
+ {Name: "PHPSESSID", Value: "def", Path: "/"},
+ },
+ }
+
+ if err := saveCachedSession(user, snap); err != nil {
+ t.Fatalf("save: %v", err)
+ }
+ got, err := loadCachedSession(user)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if got == nil {
+ t.Fatal("load returned nil cache after save")
+ }
+ if got.Session.UserID != snap.UserID || got.Session.AuthToken != snap.AuthToken {
+ t.Errorf("session mismatch: got %+v, want %+v", got.Session, snap)
+ }
+ if len(got.Session.Cookies) != 2 {
+ t.Errorf("cookies: got %d, want 2", len(got.Session.Cookies))
+ }
+
+ // Different user → cache miss, not error.
+ missed, err := loadCachedSession("someone-else@example.com")
+ if err != nil {
+ t.Fatalf("load other user: %v", err)
+ }
+ if missed != nil {
+ t.Error("expected nil cache for different user")
+ }
+
+ // Delete clears it.
+ if _, err := DeleteCachedSession(); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, err := os.Stat(SessionCachePath()); !os.IsNotExist(err) {
+ t.Errorf("cache file still exists after delete: %v", err)
+ }
+ // Delete on missing cache is a no-op.
+ if _, err := DeleteCachedSession(); err != nil {
+ t.Errorf("delete-when-missing should be no-op, got %v", err)
+ }
+}
From aac6c4b747d51086b1d76fb4fd22621012a776ca Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 18 May 2026 23:41:32 -0400
Subject: [PATCH 2/3] =?UTF-8?q?fixup:=20address=20PR=20review=20=E2=80=94?=
=?UTF-8?q?=20README,=20gofmt,=20cache=20schema=20version?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review feedback on #44:
- **Must fix #1: README.md stale.** Replaced the "no token cache"
paragraph with the cache location ($XDG_CACHE_HOME), the
CRONOMETER_NO_CACHE opt-out, the `auth logout` subcommand, and a
security note ("treat session.json like a password"). Folds in the
reviewer's nice-to-have #5 (security guidance) at the same time.
- **Must fix #2: gofmt.** Ran `gofmt -w` on `cmd/format.go` and
`internal/cronoclient/daterange_test.go` (trailing blank line + struct
field alignment). `internal/cronoapi/gwt.go` is also flagged but is
pre-existing from #37 — landed as a separate chore commit so this
PR's diff stays scoped to release-blocker work.
- **Nice-to-have #4: cache schema version.** Added
`cacheSchemaVersion = 1` and a `Version int` field on cachedSession.
Old/mismatched versions are silently treated as a miss so a future
incompatible bump triggers a transparent re-login instead of a
JSON-shape error. New test: TestSessionCacheVersionMismatch.
Skipped: race on concurrent fresh logins (#6) — reviewer agreed this is
fine for a single-user CLI.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
README.md | 12 ++++++++++-
cmd/format.go | 1 -
internal/cronoclient/daterange_test.go | 6 +++---
internal/cronoclient/session.go | 10 +++++++++
internal/cronoclient/session_test.go | 29 ++++++++++++++++++++++++++
5 files changed, 53 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 4482d42..e9032a1 100644
--- a/README.md
+++ b/README.md
@@ -78,7 +78,17 @@ export CRONOMETER_USERNAME="you@example.com"
export CRONOMETER_PASSWORD="your-cronometer-password"
```
-The CLI logs in on every invocation; there's no token cache. Cronometer doesn't (yet) offer SSO or API tokens for individuals, so a real password is the only auth option.
+Cronometer doesn't (yet) offer SSO or API tokens for individuals, so a real password is the only auth option.
+
+After the first successful login, the session (auth token + cookies) is cached at `$XDG_CACHE_HOME/crono-export/session.json` (file mode `0600`, directory mode `0700`; on macOS this resolves to `~/Library/Caches/crono-export/session.json`). Subsequent invocations reuse the cached session and skip the login handshake — useful for LLM-agent workflows that fire several commands in quick succession (a fresh login on every call trips Cronometer's "Too Many Attempts" throttle after ~6 requests). When the cached session goes stale, the CLI transparently re-logs in and retries once.
+
+Escape hatches:
+
+- `CRONOMETER_NO_CACHE=1` forces a fresh login on every invocation (and skips writing the cache).
+- `crono-export auth logout` deletes the cached session.
+- `crono-export auth status` reports whether credentials are set and whether a session is cached.
+
+Treat `session.json` like a password: don't sync it to a shared backup, and don't commit it.
## Usage
diff --git a/cmd/format.go b/cmd/format.go
index 646b1ac..7829670 100644
--- a/cmd/format.go
+++ b/cmd/format.go
@@ -413,4 +413,3 @@ func pickKey(row map[string]any, candidates ...string) string {
}
return ""
}
-
diff --git a/internal/cronoclient/daterange_test.go b/internal/cronoclient/daterange_test.go
index 24f474e..2ad0045 100644
--- a/internal/cronoclient/daterange_test.go
+++ b/internal/cronoclient/daterange_test.go
@@ -10,10 +10,10 @@ func TestResolveDateRange(t *testing.T) {
day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
cases := []struct {
- name string
- since, until string
+ name string
+ since, until string
wantStart, wantEnd time.Time
- wantEmpty bool
+ wantEmpty bool
}{
{
name: "both empty defaults to 7d ending today",
diff --git a/internal/cronoclient/session.go b/internal/cronoclient/session.go
index 4d29d47..2dad12f 100644
--- a/internal/cronoclient/session.go
+++ b/internal/cronoclient/session.go
@@ -11,10 +11,16 @@ import (
"github.com/quantcli/crono-export-cli/internal/cronoapi"
)
+// cacheSchemaVersion is bumped whenever the on-disk session shape
+// changes incompatibly. An older cache is silently ignored (treated
+// as a miss) so existing users transparently re-login on upgrade.
+const cacheSchemaVersion = 1
+
// cachedSession is the on-disk representation of a Cronometer login.
// We key by username so flipping CRONOMETER_USERNAME invalidates the
// cache automatically instead of replaying another user's session.
type cachedSession struct {
+ Version int `json:"version"`
Username string `json:"username"`
Session cronoapi.Session `json:"session"`
SavedAt time.Time `json:"saved_at"`
@@ -60,6 +66,9 @@ func loadCachedSession(user string) (*cachedSession, error) {
if err := json.Unmarshal(data, &s); err != nil {
return nil, nil
}
+ if s.Version != cacheSchemaVersion {
+ return nil, nil
+ }
if s.Username != user {
return nil, nil
}
@@ -81,6 +90,7 @@ func saveCachedSession(user string, snap cronoapi.Session) error {
return err
}
data, err := json.Marshal(cachedSession{
+ Version: cacheSchemaVersion,
Username: user,
Session: snap,
SavedAt: time.Now(),
diff --git a/internal/cronoclient/session_test.go b/internal/cronoclient/session_test.go
index 6d13305..890e0c3 100644
--- a/internal/cronoclient/session_test.go
+++ b/internal/cronoclient/session_test.go
@@ -3,6 +3,7 @@ package cronoclient
import (
"net/http"
"os"
+ "path/filepath"
"testing"
"github.com/quantcli/crono-export-cli/internal/cronoapi"
@@ -64,3 +65,31 @@ func TestSessionCacheRoundTrip(t *testing.T) {
t.Errorf("delete-when-missing should be no-op, got %v", err)
}
}
+
+// TestSessionCacheVersionMismatch confirms that a cache written under
+// a different schema version is silently treated as a miss, so a
+// future incompatible bump triggers a transparent re-login instead of
+// a JSON-shape error.
+func TestSessionCacheVersionMismatch(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("XDG_CACHE_HOME", tmp)
+ t.Setenv("HOME", tmp)
+
+ p := SessionCachePath()
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ // Hand-written cache pretending to be a future schema version.
+ body := `{"version":999,"username":"alice@example.com","session":{"user_id":1,"auth_token":"deadbeef","cookies":null}}`
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := loadCachedSession("alice@example.com")
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if got != nil {
+ t.Errorf("expected nil cache for mismatched version, got %+v", got)
+ }
+}
From 8c2fd2660aef09f7a076e9199f63fa360a118413 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 18 May 2026 23:41:41 -0400
Subject: [PATCH 3/3] chore: gofmt internal/cronoapi/gwt.go (preexisting drift
from #37)
Pure formatting: column-aligned const block and var block. No logic change.
Flagged by `gofmt -l .` during review of #44.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
internal/cronoapi/gwt.go | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/internal/cronoapi/gwt.go b/internal/cronoapi/gwt.go
index b45b80b..be01ca3 100644
--- a/internal/cronoapi/gwt.go
+++ b/internal/cronoapi/gwt.go
@@ -17,8 +17,8 @@ import (
const DefaultGWTPermutation = "7B121DC5483BF272B1BC1916DA9FA963"
const (
- gwtModuleBase = "https://cronometer.com/cronometer/"
- gwtServiceIfc = "com.cronometer.shared.rpc.CronometerService"
+ gwtModuleBase = "https://cronometer.com/cronometer/"
+ gwtServiceIfc = "com.cronometer.shared.rpc.CronometerService"
gwtContentType = "text/x-gwt-rpc; charset=UTF-8"
)
@@ -88,10 +88,10 @@ func logoutBody(permutation, authToken string) string {
// exception. parseGwtOK strips the //OK prefix and returns the inner
// payload (everything between the [ and the trailing ]).
var (
- gwtOKRe = regexp.MustCompile(`^//OK\[(.*)\]\s*$`)
- gwtUserIDRe = regexp.MustCompile(`^//OK\[(\d+),`)
- gwtAuthTokRe = regexp.MustCompile(`"([0-9a-fA-F]{32})"`)
- gwtNonceRe = regexp.MustCompile(`\["([0-9a-fA-F]{32})"\]`)
+ gwtOKRe = regexp.MustCompile(`^//OK\[(.*)\]\s*$`)
+ gwtUserIDRe = regexp.MustCompile(`^//OK\[(\d+),`)
+ gwtAuthTokRe = regexp.MustCompile(`"([0-9a-fA-F]{32})"`)
+ gwtNonceRe = regexp.MustCompile(`\["([0-9a-fA-F]{32})"\]`)
)
// parseAuthenticateResponse pulls (userID, sessionAuthToken) out of the