Skip to content

Commit eeb7357

Browse files
LeadGoEngineerPaperclip-Paperclip
andcommitted
feat(phase3b): replace gocronometer with clean-room MIT cronoapi client (QUA-37)
Drops the GPL-2.0 github.com/jrmycanady/gocronometer dependency from the production module. crono-export-cli is now MIT-clean end to end: `go mod why github.com/jrmycanady/gocronometer` returns "not needed by main module" and the transitive set in `go list -m all` contains only permissive (MIT / BSD / Apache-2.0) licences. Clean-room provenance: - Inputs: the Phase 1 protocol spec (docs/cronometer-protocol.md) and the Phase 3a wire-shape capture (internal/cronoclient/testdata/cronometer/WIRE_SHAPES.md). - No source from github.com/jrmycanady/gocronometer was consulted at any point during authoring. The public surface we already named in our own MIT call sites was sufficient to drive the implementation. What ships: - internal/cronoapi: new MIT package — Client, Login, Logout, the five Export* methods, GWT-RPC framing helpers, and CSV → typed-record parsers. Pure stdlib (no new deps; x/net falls out of the graph too). - internal/cronoclient: wrapper rewired onto cronoapi; export methods and the public Client/DateRange surface are unchanged. - cmd/format.go: types swapped to cronoapi.* aliases — reflection walker keeps working because record field names follow the same <Name><Unit> convention captured in the spec. - internal/cronoapi/client_test.go: httptest-driven coverage of the full 14-exchange storyboard with hand-authored CSV samples (real CSV bodies were redacted from Phase 3a fixtures for privacy; samples exercise the documented shapes only). - LICENSING.md: discloses the GPL-2.0 history and reproducibility steps. - README.md: replaces the old "via jrmycanady/gocronometer" footer with the MIT-clean posture. Verification: - `go vet ./...` clean. - `go test ./...` passes (cronoapi unit tests green). - `go test -tags=compat -run TestContractDates ./...` passes against the freshly built binary; quantcli/common compat machine-attestation is intact. - `go mod why github.com/jrmycanady/gocronometer` → "main module does not need package". tools/wirecapture/ pins gocronometer in a separate sub-module for capture-only use; it is not imported by the production binary and does not affect the main module graph. Refs: QUA-12 plan v4 (clean-room track), QUA-37 (this PR), QUA-36 (reverted v3 deprecation pivot). Co-Authored-By: Paperclip <[email protected]>
1 parent bd204fe commit eeb7357

13 files changed

Lines changed: 1547 additions & 26 deletions

File tree

LICENSING.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Licensing history
2+
3+
`crono-export-cli` is MIT-licensed end to end as of the release that
4+
ships [QUA-37](https://github.com/quantcli/common). This document
5+
records how the project got there.
6+
7+
## Current state
8+
9+
- `crono-export-cli`'s own code: **MIT** (see [LICENSE](LICENSE)).
10+
- The Cronometer HTTP client in `internal/cronoapi/` is fresh-authored
11+
in-tree, also MIT.
12+
- `go mod why github.com/jrmycanady/gocronometer` returns "not needed
13+
by main module" — the GPL-2.0 client is no longer a transitive
14+
dependency.
15+
- Remaining direct/indirect dependencies are tracked in
16+
[`go.mod`](go.mod); all use permissive (MIT / BSD / Apache-2.0)
17+
licences.
18+
19+
## History
20+
21+
Releases **prior to the MIT-clean cut** depended on
22+
[`github.com/jrmycanady/gocronometer`](https://github.com/jrmycanady/gocronometer)
23+
(GPL-2.0). Because Go programs link statically, those binaries linked
24+
GPL-2.0 code and inherited the GPL-2.0 source-availability obligations
25+
for the resulting combined work. Users on those builds who never
26+
redistributed binaries were unaffected in practice; redistributors
27+
needed to honour GPL-2.0.
28+
29+
The replacement `internal/cronoapi` package was authored under the
30+
clean-room rules captured in [QUA-12](https://github.com/quantcli/common)
31+
plan v4:
32+
33+
- The Phase 1 protocol spec in [`docs/cronometer-protocol.md`](docs/cronometer-protocol.md)
34+
was derived only from `crono-export-cli`'s own MIT-licensed call
35+
sites — no `gocronometer` source was consulted.
36+
- Phase 3a recorded the live HTTP behaviour of `cronometer.com` via a
37+
fresh-authored `http.RoundTripper` and committed redacted captures
38+
to [`internal/cronoclient/testdata/cronometer/`](internal/cronoclient/testdata/cronometer/).
39+
Real session cookies, anti-CSRF tokens, per-export nonces, and
40+
account data were stripped before commit; only metadata (URL,
41+
status, headers) survived.
42+
- Phase 3b authored `internal/cronoapi` against the wire-shape document
43+
produced in Phase 3a and the public API surface (`go doc`) used by
44+
our own MIT call sites. `gocronometer` source remained out of scope
45+
for the duration.
46+
47+
The `tools/wirecapture/` sub-module pins `gocronometer` for capture-only
48+
use; it is **not** imported by the production binary and stays out of
49+
the main module's `go.mod`. It is retained so future wire-shape
50+
recaptures (e.g. after Cronometer rotates the GWT permutation hash or
51+
adds new nutrient columns) can be performed without re-introducing the
52+
GPL dependency to production.
53+
54+
## Reproducing the audit
55+
56+
```sh
57+
# Should show the gocronometer dep is no longer needed by the main
58+
# module:
59+
go mod why github.com/jrmycanady/gocronometer
60+
61+
# Should not list any GPL-licensed dependency. license-detection
62+
# tools that read Go module metadata (e.g., go-licenses) will return
63+
# only MIT / BSD / Apache-2.0 packages.
64+
go list -m all
65+
```
66+
67+
## ToS
68+
69+
Cronometer's Terms of Service §10(b) governs automated access. The
70+
board has accepted that risk on record as part of
71+
[QUA-12](https://github.com/quantcli/common) plan v4; this CLI's
72+
licensing posture is independent of that policy question and is
73+
resolved here at the source-code layer only.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,10 @@ LLM agents: run `crono-export prime` for a one-screen orientation describing bot
166166

167167
[Cronometer](https://cronometer.com) is a nutrition tracking app with one of the best micronutrient databases of any consumer tool — a major reason it's commonly recommended for bariatric patients, anyone tracking specific vitamin/mineral targets, or athletes managing recovery nutrition.
168168

169-
This CLI is an unofficial tool for exporting your own data. It uses the same web export endpoints the Cronometer SPA uses, via [`jrmycanady/gocronometer`](https://github.com/jrmycanady/gocronometer). It is intended for personal single-user use only — see the upstream library's notes on appropriate use.
169+
This CLI is an unofficial tool for exporting your own data. It speaks directly to the same web export endpoints the Cronometer SPA uses, via an MIT-licensed in-tree HTTP client (`internal/cronoapi`). It is intended for personal single-user use only.
170170

171171
## License
172172

173173
MIT — see [LICENSE](LICENSE).
174174

175-
The underlying [`gocronometer`](https://github.com/jrmycanady/gocronometer) library is GPLv2-licensed.
175+
The CLI is MIT-clean: it has no transitive GPL dependencies. See [LICENSING.md](LICENSING.md) for the history.

cmd/format.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"sort"
1010
"strings"
1111

12-
"github.com/jrmycanady/gocronometer"
12+
"github.com/quantcli/crono-export-cli/internal/cronoapi"
1313
"github.com/spf13/cobra"
1414
)
1515

@@ -61,13 +61,13 @@ func emit(cmd *cobra.Command, kind recordKind, v any) error {
6161
func renderMarkdown(w io.Writer, kind recordKind, v any) error {
6262
switch kind {
6363
case kindServings:
64-
recs, _ := v.(gocronometer.ServingRecords)
64+
recs, _ := v.(cronoapi.ServingRecords)
6565
return renderServings(w, recs)
6666
case kindBiometrics:
67-
recs, _ := v.(gocronometer.BiometricRecords)
67+
recs, _ := v.(cronoapi.BiometricRecords)
6868
return renderBiometrics(w, recs)
6969
case kindExercises:
70-
recs, _ := v.(gocronometer.ExerciseRecords)
70+
recs, _ := v.(cronoapi.ExerciseRecords)
7171
return renderExercises(w, recs)
7272
case kindNutrition:
7373
rows, _ := v.([]map[string]string)
@@ -115,12 +115,12 @@ func strippedSuffix(field string) (name, unit string) {
115115

116116
// ---- servings ---------------------------------------------------------
117117

118-
func renderServings(w io.Writer, recs gocronometer.ServingRecords) error {
118+
func renderServings(w io.Writer, recs cronoapi.ServingRecords) error {
119119
if len(recs) == 0 {
120120
return emptyMsg(w)
121121
}
122122
// Group by local calendar date.
123-
byDate := map[string][]gocronometer.ServingRecord{}
123+
byDate := map[string][]cronoapi.ServingRecord{}
124124
for _, r := range recs {
125125
d := r.RecordedTime.Format("2006-01-02")
126126
byDate[d] = append(byDate[d], r)
@@ -144,7 +144,7 @@ func renderServings(w io.Writer, recs gocronometer.ServingRecords) error {
144144
return nil
145145
}
146146

147-
func renderServingRecord(w io.Writer, r gocronometer.ServingRecord) {
147+
func renderServingRecord(w io.Writer, r cronoapi.ServingRecord) {
148148
header := fmt.Sprintf("### %s · %s", strDefault(r.Group, "—"), r.FoodName)
149149
if r.QuantityValue != 0 || r.QuantityUnits != "" {
150150
header += fmt.Sprintf(" (%s %s)", fmtFloat(r.QuantityValue), r.QuantityUnits)
@@ -192,11 +192,11 @@ func strDefault(s, fallback string) string {
192192

193193
// ---- biometrics -------------------------------------------------------
194194

195-
func renderBiometrics(w io.Writer, recs gocronometer.BiometricRecords) error {
195+
func renderBiometrics(w io.Writer, recs cronoapi.BiometricRecords) error {
196196
if len(recs) == 0 {
197197
return emptyMsg(w)
198198
}
199-
byDate := map[string][]gocronometer.BiometricRecord{}
199+
byDate := map[string][]cronoapi.BiometricRecord{}
200200
for _, r := range recs {
201201
d := r.RecordedTime.Format("2006-01-02")
202202
byDate[d] = append(byDate[d], r)
@@ -224,11 +224,11 @@ func renderBiometrics(w io.Writer, recs gocronometer.BiometricRecords) error {
224224

225225
// ---- exercises --------------------------------------------------------
226226

227-
func renderExercises(w io.Writer, recs gocronometer.ExerciseRecords) error {
227+
func renderExercises(w io.Writer, recs cronoapi.ExerciseRecords) error {
228228
if len(recs) == 0 {
229229
return emptyMsg(w)
230230
}
231-
byDate := map[string][]gocronometer.ExerciseRecord{}
231+
byDate := map[string][]cronoapi.ExerciseRecord{}
232232
for _, r := range recs {
233233
d := r.RecordedTime.Format("2006-01-02")
234234
byDate[d] = append(byDate[d], r)

go.mod

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ module github.com/quantcli/crono-export-cli
33
go 1.25.10
44

55
require (
6-
github.com/jrmycanady/gocronometer v1.5.1
76
github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b
87
github.com/spf13/cobra v1.10.2
98
)
109

1110
require (
1211
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1312
github.com/spf13/pflag v1.0.9 // indirect
14-
golang.org/x/net v0.46.0 // indirect
1513
)

go.sum

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
22
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
33
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4-
github.com/jrmycanady/gocronometer v1.5.1 h1:m2J31jEuLlL4RRdQLY33IFs4TAwmfevvJYl2SZxBSQ0=
5-
github.com/jrmycanady/gocronometer v1.5.1/go.mod h1:swnvYB6twU20LDzNpAz8JOX5mCHktTW06zlSXmmyZWc=
64
github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b h1:fO7EfkEqzLRC8Ev22jIq05fPs+JwAB7bCDy6FA+GA5k=
75
github.com/quantcli/common/compat v0.0.0-20260510225630-4c588c19cd1b/go.mod h1:VBC/zEphSZgCZS1rhWsR3A8EWYSbTkP/MwqWHL7266s=
86
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -11,6 +9,4 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
119
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
1210
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
1311
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
14-
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
15-
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
1612
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/cronoapi/client.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package cronoapi
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/http/cookiejar"
9+
"net/url"
10+
"regexp"
11+
"strings"
12+
"time"
13+
)
14+
15+
// DefaultBaseURL is the production Cronometer host.
16+
const DefaultBaseURL = "https://cronometer.com"
17+
18+
const (
19+
defaultTimeout = 60 * time.Second
20+
userAgentHdr = "crono-export-cli (clean-room; https://github.com/quantcli/crono-export-cli)"
21+
)
22+
23+
// Client is an authenticated Cronometer session. Construct it with
24+
// NewClient, then call Login. After Login the client holds the GWT
25+
// session auth token and user ID and can call any of the Export*
26+
// methods. Logout tears down the session, best-effort.
27+
//
28+
// Client is not safe for concurrent use by multiple goroutines.
29+
type Client struct {
30+
HTTPClient *http.Client
31+
32+
baseURL string
33+
permutation string
34+
userAgent string
35+
36+
userID int
37+
authToken string
38+
}
39+
40+
// NewClient returns a fresh Client. If httpClient is nil a default
41+
// client with a 60-second timeout and a cookie jar is constructed.
42+
// If httpClient is non-nil but has no Jar set, a cookie jar is
43+
// installed so session cookies round-trip across requests.
44+
func NewClient(httpClient *http.Client) *Client {
45+
if httpClient == nil {
46+
jar, _ := cookiejar.New(nil)
47+
httpClient = &http.Client{
48+
Jar: jar,
49+
Timeout: defaultTimeout,
50+
}
51+
} else if httpClient.Jar == nil {
52+
jar, _ := cookiejar.New(nil)
53+
httpClient.Jar = jar
54+
}
55+
return &Client{
56+
HTTPClient: httpClient,
57+
baseURL: DefaultBaseURL,
58+
permutation: DefaultGWTPermutation,
59+
userAgent: userAgentHdr,
60+
}
61+
}
62+
63+
// SetBaseURL overrides the Cronometer host. Intended for tests.
64+
func (c *Client) SetBaseURL(u string) { c.baseURL = strings.TrimRight(u, "/") }
65+
66+
// SetPermutation overrides the GWT permutation hash sent on
67+
// /cronometer/app calls. Useful when Cronometer rotates their deploy
68+
// hash and we need to point at the new value without a code release.
69+
func (c *Client) SetPermutation(p string) { c.permutation = p }
70+
71+
// AuthToken returns the GWT session auth token from the most recent
72+
// successful Login. Empty before Login or after Logout. Exposed for
73+
// debugging; not normally needed by callers.
74+
func (c *Client) AuthToken() string { return c.authToken }
75+
76+
// UserID returns the Cronometer user ID from the most recent
77+
// successful Login. Zero before Login or after Logout.
78+
func (c *Client) UserID() int { return c.userID }
79+
80+
// anticsrfRe extracts the hidden anti-CSRF token from the login HTML.
81+
// WIRE_SHAPES.md §(1).
82+
var anticsrfRe = regexp.MustCompile(`name="anticsrf"\s+value="([^"]+)"`)
83+
84+
// fetchAntiCSRF performs the anonymous GET /login/ that bootstraps the
85+
// session cookie jar and returns the anti-CSRF form token embedded in
86+
// the response HTML.
87+
func (c *Client) fetchAntiCSRF(ctx context.Context) (string, error) {
88+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/login/", nil)
89+
if err != nil {
90+
return "", err
91+
}
92+
req.Header.Set("User-Agent", c.userAgent)
93+
resp, err := c.HTTPClient.Do(req)
94+
if err != nil {
95+
return "", fmt.Errorf("GET /login/: %w", err)
96+
}
97+
defer resp.Body.Close()
98+
if resp.StatusCode != http.StatusOK {
99+
return "", fmt.Errorf("GET /login/: HTTP %d", resp.StatusCode)
100+
}
101+
body, err := io.ReadAll(resp.Body)
102+
if err != nil {
103+
return "", fmt.Errorf("read /login/ body: %w", err)
104+
}
105+
m := anticsrfRe.FindSubmatch(body)
106+
if m == nil {
107+
return "", fmt.Errorf("anti-CSRF token not found in /login/ response")
108+
}
109+
return string(m[1]), nil
110+
}
111+
112+
// submitLogin posts the credential form to /login. Cronometer responds
113+
// with a small JSON body and additional Set-Cookie entries on success.
114+
// The cookie jar on c.HTTPClient picks those up automatically. We do
115+
// not parse the JSON success body — its only documented failure mode
116+
// is `{"error":"AntiCSRF Token Invalid"}`, which we surface explicitly.
117+
func (c *Client) submitLogin(ctx context.Context, username, password, csrfToken string) error {
118+
form := url.Values{}
119+
form.Set("anticsrf", csrfToken)
120+
form.Set("password", password)
121+
form.Set("username", username)
122+
123+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/login", strings.NewReader(form.Encode()))
124+
if err != nil {
125+
return err
126+
}
127+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
128+
req.Header.Set("User-Agent", c.userAgent)
129+
130+
resp, err := c.HTTPClient.Do(req)
131+
if err != nil {
132+
return fmt.Errorf("POST /login: %w", err)
133+
}
134+
defer resp.Body.Close()
135+
body, err := io.ReadAll(resp.Body)
136+
if err != nil {
137+
return fmt.Errorf("read /login body: %w", err)
138+
}
139+
if resp.StatusCode != http.StatusOK {
140+
return fmt.Errorf("POST /login: HTTP %d: %s", resp.StatusCode, truncate(string(body), 200))
141+
}
142+
bodyStr := string(body)
143+
if strings.Contains(bodyStr, `"error"`) {
144+
return fmt.Errorf("login failed: %s", truncate(bodyStr, 200))
145+
}
146+
return nil
147+
}
148+
149+
// Login performs the three-step Cronometer authentication handshake:
150+
//
151+
// 1. GET /login/ — bootstrap cookies + read anti-CSRF token.
152+
// 2. POST /login — credential submission.
153+
// 3. POST /cronometer/app (GWT-RPC authenticate) — fetch userID +
154+
// session auth token used to mint export nonces.
155+
//
156+
// Steps map 1:1 to WIRE_SHAPES.md §(1), §(2), and §(3).
157+
func (c *Client) Login(ctx context.Context, username, password string) error {
158+
if username == "" || password == "" {
159+
return fmt.Errorf("login: username and password required")
160+
}
161+
csrf, err := c.fetchAntiCSRF(ctx)
162+
if err != nil {
163+
return err
164+
}
165+
if err := c.submitLogin(ctx, username, password, csrf); err != nil {
166+
return err
167+
}
168+
169+
// Step 3: GWT-RPC authenticate. UTC offset in minutes for the host
170+
// timezone — captured payload sent -300 (NYC DST). We send the
171+
// current local zone's offset so the response reflects the user's
172+
// local calendar.
173+
_, offsetSec := time.Now().Zone()
174+
body, err := c.gwtCall(ctx, authenticateBody(c.permutation, offsetSec/60))
175+
if err != nil {
176+
return fmt.Errorf("authenticate: %w", err)
177+
}
178+
uid, tok, err := parseAuthenticateResponse(body)
179+
if err != nil {
180+
return err
181+
}
182+
c.userID = uid
183+
c.authToken = tok
184+
return nil
185+
}
186+
187+
// Logout calls the GWT-RPC logout method. WIRE_SHAPES.md §(14) notes
188+
// this is best-effort: crono-export-cli already calls it via defer and
189+
// ignores the returned error.
190+
func (c *Client) Logout(ctx context.Context) error {
191+
if c.authToken == "" {
192+
return nil
193+
}
194+
body, err := c.gwtCall(ctx, logoutBody(c.permutation, c.authToken))
195+
c.authToken = ""
196+
c.userID = 0
197+
if err != nil {
198+
return err
199+
}
200+
if !strings.HasPrefix(body, "//OK") {
201+
return fmt.Errorf("logout: unexpected response: %s", truncate(body, 80))
202+
}
203+
return nil
204+
}

0 commit comments

Comments
 (0)