Skip to content

Commit f250b34

Browse files
DTTerastarclaude
andauthored
v0.2.0 release prep: 8 fixes for ship-blocker QA findings (#44)
* feat: release-blocker bundle for v0.2.0 (#17 #18 #21 #16 #15 #23 #39 #40) 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) <[email protected]> * fixup: address PR review — README, gofmt, cache schema version 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) <[email protected]> * 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) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 8613433 commit f250b34

19 files changed

Lines changed: 711 additions & 86 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ cronometer-capture/
4848
tools/wirecapture/captures/
4949
tools/wirecapture/wirecapture
5050
*.unredacted.json
51+
.DS_Store

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,17 @@ export CRONOMETER_USERNAME="[email protected]"
7878
export CRONOMETER_PASSWORD="your-cronometer-password"
7979
```
8080

81-
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.
81+
Cronometer doesn't (yet) offer SSO or API tokens for individuals, so a real password is the only auth option.
82+
83+
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.
84+
85+
Escape hatches:
86+
87+
- `CRONOMETER_NO_CACHE=1` forces a fresh login on every invocation (and skips writing the cache).
88+
- `crono-export auth logout` deletes the cached session.
89+
- `crono-export auth status` reports whether credentials are set and whether a session is cached.
90+
91+
Treat `session.json` like a password: don't sync it to a shared backup, and don't commit it.
8292

8393
## Usage
8494

cmd/auth.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66

77
"github.com/spf13/cobra"
8+
9+
"github.com/quantcli/crono-export-cli/internal/cronoclient"
810
)
911

1012
var authCmd = &cobra.Command{
@@ -36,12 +38,46 @@ https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`,
3638
case pass == "":
3739
return fmt.Errorf("missing CRONOMETER_PASSWORD")
3840
}
39-
fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (env-var auth, no token cache)\n", user)
41+
cacheState := "no cache"
42+
if os.Getenv("CRONOMETER_NO_CACHE") == "" {
43+
if p := cronoclient.SessionCachePath(); p != "" {
44+
if _, err := os.Stat(p); err == nil {
45+
cacheState = "session cached"
46+
} else {
47+
cacheState = "no session cached"
48+
}
49+
}
50+
} else {
51+
cacheState = "cache disabled (CRONOMETER_NO_CACHE set)"
52+
}
53+
fmt.Fprintf(cmd.OutOrStdout(), "credentials present for %s (%s)\n", user, cacheState)
54+
return nil
55+
},
56+
}
57+
58+
var authLogoutCmd = &cobra.Command{
59+
Use: "logout",
60+
Short: "Delete the cached Cronometer session, forcing a fresh login next call",
61+
Long: `Remove the on-disk session cache at $XDG_CACHE_HOME/crono-export/session.json.
62+
Useful after rotating your password or when you suspect a stale session
63+
is causing failures.
64+
65+
This is a local-only operation: it does NOT call Cronometer's logout
66+
endpoint (that would invalidate the cached cookies for a session we've
67+
already deleted). The next export call will perform a fresh login.`,
68+
Args: cobra.NoArgs,
69+
RunE: func(cmd *cobra.Command, _ []string) error {
70+
p, err := cronoclient.DeleteCachedSession()
71+
if err != nil {
72+
return err
73+
}
74+
fmt.Fprintf(cmd.OutOrStdout(), "session cache cleared (%s)\n", p)
4075
return nil
4176
},
4277
}
4378

4479
func init() {
4580
authCmd.AddCommand(authStatusCmd)
81+
authCmd.AddCommand(authLogoutCmd)
4682
rootCmd.AddCommand(authCmd)
4783
}

cmd/biometrics.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ import (
77
)
88

99
var biometricsCmd = &cobra.Command{
10-
Use: "biometrics",
11-
Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)",
10+
Use: "biometrics",
11+
Short: "Export biometric records (weight, body fat, blood pressure, custom metrics)",
12+
Args: cobra.NoArgs,
13+
PreRunE: ValidateExportFlags,
1214
RunE: func(cmd *cobra.Command, _ []string) error {
1315
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
1416
if err != nil {
1517
return err
1618
}
19+
if rng.IsEmpty() {
20+
return emit(cmd, kindBiometrics, emptyValueFor(kindBiometrics))
21+
}
1722
ctx := cmd.Context()
1823
c, err := cronoclient.NewLoggedIn(ctx)
1924
if err != nil {

cmd/exercises.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ import (
77
)
88

99
var exercisesCmd = &cobra.Command{
10-
Use: "exercises",
11-
Short: "Export logged exercises (cardio, strength, custom activities)",
10+
Use: "exercises",
11+
Short: "Export logged exercises (cardio, strength, custom activities)",
12+
Args: cobra.NoArgs,
13+
PreRunE: ValidateExportFlags,
1214
RunE: func(cmd *cobra.Command, _ []string) error {
1315
rng, err := cronoclient.ParseDateRangeFromFlags(cmd)
1416
if err != nil {
1517
return err
1618
}
19+
if rng.IsEmpty() {
20+
return emit(cmd, kindExercises, emptyValueFor(kindExercises))
21+
}
1722
ctx := cmd.Context()
1823
c, err := cronoclient.NewLoggedIn(ctx)
1924
if err != nil {

cmd/format.go

Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ func AddFormatFlags(cmd *cobra.Command) {
3131
"Output format: markdown (default, fitdown-style) or json")
3232
}
3333

34+
// ValidateExportFlags is a PreRunE that fails fast on bad --format or date
35+
// flags before any network call is made. Without this, a typo in --format
36+
// would burn a Cronometer login attempt against the rate limit.
37+
func ValidateExportFlags(cmd *cobra.Command, _ []string) error {
38+
if _, err := chosenFormat(cmd); err != nil {
39+
return err
40+
}
41+
return nil
42+
}
43+
3444
func chosenFormat(cmd *cobra.Command) (string, error) {
3545
f, _ := cmd.Flags().GetString("format")
3646
switch f {
@@ -70,20 +80,40 @@ func renderMarkdown(w io.Writer, kind recordKind, v any) error {
7080
recs, _ := v.(cronoapi.ExerciseRecords)
7181
return renderExercises(w, recs)
7282
case kindNutrition:
73-
rows, _ := v.([]map[string]string)
83+
rows, _ := v.([]map[string]any)
7484
return renderNutrition(w, rows)
7585
case kindNotes:
76-
rows, _ := v.([]map[string]string)
86+
rows, _ := v.([]map[string]any)
7787
return renderNotes(w, rows)
7888
}
7989
return fmt.Errorf("renderMarkdown: unknown kind %d", kind)
8090
}
8191

8292
// ---- shared helpers ---------------------------------------------------
8393

84-
func emptyMsg(w io.Writer) error {
85-
_, err := fmt.Fprintln(w, "_(no records in window)_")
86-
return err
94+
// noteEmpty writes a friendly "no records" note to stderr so humans see it,
95+
// while keeping stdout clean per the contract's "data only on stdout" rule.
96+
// w is ignored — kept for the renderer signatures.
97+
func noteEmpty(_ io.Writer) error {
98+
fmt.Fprintln(os.Stderr, "(no records in window)")
99+
return nil
100+
}
101+
102+
// emptyValueFor returns the typed empty value for a record kind. Used
103+
// when the caller knows there are no records (e.g. inverted date range)
104+
// and wants to emit them without making a network call.
105+
func emptyValueFor(kind recordKind) any {
106+
switch kind {
107+
case kindServings:
108+
return cronoapi.ServingRecords{}
109+
case kindBiometrics:
110+
return cronoapi.BiometricRecords{}
111+
case kindExercises:
112+
return cronoapi.ExerciseRecords{}
113+
case kindNutrition, kindNotes:
114+
return []map[string]any{}
115+
}
116+
return nil
87117
}
88118

89119
// fmtFloat trims trailing zeros so 1.95 → "1.95" and 100.000 → "100".
@@ -103,7 +133,7 @@ func strippedSuffix(field string) (name, unit string) {
103133
{"Kcal", "kcal"},
104134
{"Mg", "mg"},
105135
{"Ug", "µg"},
106-
{"UI", "IU"},
136+
{"IU", "IU"},
107137
{"G", "g"},
108138
} {
109139
if strings.HasSuffix(field, suf.go_) && len(field) > len(suf.go_) {
@@ -117,7 +147,7 @@ func strippedSuffix(field string) (name, unit string) {
117147

118148
func renderServings(w io.Writer, recs cronoapi.ServingRecords) error {
119149
if len(recs) == 0 {
120-
return emptyMsg(w)
150+
return noteEmpty(w)
121151
}
122152
// Group by local calendar date.
123153
byDate := map[string][]cronoapi.ServingRecord{}
@@ -194,7 +224,7 @@ func strDefault(s, fallback string) string {
194224

195225
func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {
196226
if len(recs) == 0 {
197-
return emptyMsg(w)
227+
return noteEmpty(w)
198228
}
199229
byDate := map[string][]cronoapi.BiometricRecord{}
200230
for _, r := range recs {
@@ -226,7 +256,7 @@ func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {
226256

227257
func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {
228258
if len(recs) == 0 {
229-
return emptyMsg(w)
259+
return noteEmpty(w)
230260
}
231261
byDate := map[string][]cronoapi.ExerciseRecord{}
232262
for _, r := range recs {
@@ -263,19 +293,19 @@ func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {
263293

264294
// ---- nutrition (daily totals, string-keyed CSV) ----------------------
265295

266-
func renderNutrition(w io.Writer, rows []map[string]string) error {
296+
func renderNutrition(w io.Writer, rows []map[string]any) error {
267297
if len(rows) == 0 {
268-
return emptyMsg(w)
298+
return noteEmpty(w)
269299
}
270300
// Sort by Date asc.
271301
sort.SliceStable(rows, func(i, j int) bool {
272-
return rows[i]["Date"] < rows[j]["Date"]
302+
return cellString(rows[i]["Date"]) < cellString(rows[j]["Date"])
273303
})
274304
for di, row := range rows {
275305
if di > 0 {
276306
fmt.Fprintln(w)
277307
}
278-
date := row["Date"]
308+
date := cellString(row["Date"])
279309
if date == "" {
280310
date = "(unknown date)"
281311
}
@@ -294,42 +324,54 @@ func renderNutrition(w io.Writer, rows []map[string]string) error {
294324
if isZeroish(v) {
295325
continue
296326
}
297-
fmt.Fprintf(w, "- %s: %s\n", k, v)
327+
fmt.Fprintf(w, "- %s: %s\n", k, cellString(v))
298328
}
299329
}
300330
fmt.Fprintln(w)
301331
fmt.Fprintln(w, "_zero-valued nutrients omitted; use --format json for the full row_")
302332
return nil
303333
}
304334

305-
// isZeroish reports whether a CSV value should be treated as "no data" and
306-
// hidden from the markdown output. Empty strings, "0", "0.0", "0.00", etc.
307-
// are zeroish; everything else (including "false", "true", arbitrary text)
308-
// is rendered.
309-
func isZeroish(s string) bool {
310-
if s == "" {
311-
return true
312-
}
313-
// Try numeric: if it parses to 0, it's zeroish.
314-
var f float64
315-
if _, err := fmt.Sscanf(s, "%f", &f); err == nil && f == 0 {
316-
// But only if the entire string was numeric.
317-
t := strings.TrimSpace(s)
318-
for _, r := range t {
319-
if !(r >= '0' && r <= '9') && r != '.' && r != '-' && r != '+' {
320-
return false
321-
}
335+
// cellString renders a coerced CSV cell back to a display string. Floats
336+
// drop trailing zeros so "1.5" stays "1.5" and "100" stays "100".
337+
func cellString(v any) string {
338+
switch x := v.(type) {
339+
case nil:
340+
return ""
341+
case string:
342+
return x
343+
case float64:
344+
return fmtFloat(x)
345+
case bool:
346+
if x {
347+
return "true"
322348
}
349+
return "false"
350+
default:
351+
return fmt.Sprintf("%v", x)
352+
}
353+
}
354+
355+
// isZeroish reports whether a coerced CSV value should be hidden from the
356+
// markdown output: nil, empty string, or numeric zero. "false" / "true" /
357+
// arbitrary text is rendered.
358+
func isZeroish(v any) bool {
359+
switch x := v.(type) {
360+
case nil:
323361
return true
362+
case string:
363+
return x == ""
364+
case float64:
365+
return x == 0
324366
}
325367
return false
326368
}
327369

328370
// ---- notes ------------------------------------------------------------
329371

330-
func renderNotes(w io.Writer, rows []map[string]string) error {
372+
func renderNotes(w io.Writer, rows []map[string]any) error {
331373
if len(rows) == 0 {
332-
return emptyMsg(w)
374+
return noteEmpty(w)
333375
}
334376
dateKey := pickKey(rows[0], "Day", "Date")
335377
noteKey := pickKey(rows[0], "Note", "Notes", "Comment")
@@ -339,36 +381,35 @@ func renderNotes(w io.Writer, rows []map[string]string) error {
339381
if di > 0 {
340382
fmt.Fprintln(w)
341383
}
342-
date := row[dateKey]
384+
date := cellString(row[dateKey])
343385
if date == "" {
344386
date = "(unknown date)"
345387
}
346388
header := "## " + date
347-
if t := row[timeKey]; t != "" {
389+
if t := cellString(row[timeKey]); t != "" {
348390
header += " " + t
349391
}
350392
fmt.Fprintln(w, header)
351-
if note := strings.TrimSpace(row[noteKey]); note != "" {
393+
if note := strings.TrimSpace(cellString(row[noteKey])); note != "" {
352394
fmt.Fprintln(w, note)
353395
} else {
354396
// Fall back to dumping all non-empty fields if we can't find a Note column.
355397
for k, v := range row {
356-
if k == dateKey || k == timeKey || v == "" {
398+
if k == dateKey || k == timeKey || isZeroish(v) {
357399
continue
358400
}
359-
fmt.Fprintf(w, "- %s: %s\n", k, v)
401+
fmt.Fprintf(w, "- %s: %s\n", k, cellString(v))
360402
}
361403
}
362404
}
363405
return nil
364406
}
365407

366-
func pickKey(row map[string]string, candidates ...string) string {
408+
func pickKey(row map[string]any, candidates ...string) string {
367409
for _, c := range candidates {
368410
if _, ok := row[c]; ok {
369411
return c
370412
}
371413
}
372414
return ""
373415
}
374-

cmd/format_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
func TestStrippedSuffix(t *testing.T) {
6+
cases := []struct {
7+
field, wantName, wantUnit string
8+
}{
9+
{"EnergyKcal", "Energy", "kcal"},
10+
{"CarbsG", "Carbs", "g"},
11+
{"CalciumMg", "Calcium", "mg"},
12+
{"VitaminAUg", "VitaminA", "µg"},
13+
{"VitaminDIU", "VitaminD", "IU"},
14+
{"PolyunsaturatedG", "Polyunsaturated", "g"},
15+
{"Group", "Group", ""},
16+
{"G", "G", ""},
17+
}
18+
for _, c := range cases {
19+
gotName, gotUnit := strippedSuffix(c.field)
20+
if gotName != c.wantName || gotUnit != c.wantUnit {
21+
t.Errorf("strippedSuffix(%q) = (%q, %q), want (%q, %q)",
22+
c.field, gotName, gotUnit, c.wantName, c.wantUnit)
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)