From 8736784ea49d0d8ad2810183713a70d7ece3430e Mon Sep 17 00:00:00 2001 From: milldr Date: Thu, 12 Feb 2026 11:04:12 -0500 Subject: [PATCH 1/3] Add PRD for export command using Cronometer HTTP API --- docs/prds/06-command-export.md | 283 +++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/prds/06-command-export.md diff --git a/docs/prds/06-command-export.md b/docs/prds/06-command-export.md new file mode 100644 index 0000000..17c1562 --- /dev/null +++ b/docs/prds/06-command-export.md @@ -0,0 +1,283 @@ +# PRD: export Command + +## Overview + +The `export` command fetches data from Cronometer's HTTP export API — no browser automation required. It supports three export types: daily nutrition summaries, exercises, and biometrics. This is the first command that bypasses Kernel.sh entirely, hitting Cronometer's backend directly for faster, lighter read operations. + +Long-term, this API path will replace the Kernel-based `diary` and `weight` commands. + +## User Stories + +- As a user, I want to export my daily nutrition totals from the terminal without waiting for a browser to spin up +- As a user, I want to pull my biometric history (weight, blood pressure, etc.) for a date range +- As a user, I want to export my exercise log as CSV or JSON so I can analyze it in a spreadsheet or script +- As a user, I want consistent date/range options across all crono commands + +## Command Specification + +```bash +crono export [options] +``` + +### Types + +| Type | Cronometer `generate` param | Description | +| ------------ | --------------------------- | ------------------------------------------- | +| `nutrition` | `dailySummary` | Aggregated daily nutrition totals | +| `exercises` | `exercises` | Exercise entries with duration and calories | +| `biometrics` | `biometrics` | Biometric measurements (weight, BP, etc.) | + +### Options + +| Flag | Long Form | Type | Required | Default | Description | +| ---- | --------- | ------ | -------- | ------- | ------------------------------------------------------------------- | +| `-d` | `--date` | string | no | today | Export for a specific date (YYYY-MM-DD) | +| `-r` | `--range` | string | no | - | Export for a date range (e.g. "7d", "30d", "2026-01-15:2026-02-10") | +| | `--csv` | flag | no | false | Output as raw CSV | +| | `--json` | flag | no | false | Output as JSON | + +`-d` and `-r` are mutually exclusive. If neither is provided, today's date is used for both start and end. + +`--csv` and `--json` are mutually exclusive. If neither is provided, output is plain text. + +## Examples + +### Nutrition + +```bash +# Today's nutrition summary +crono export nutrition +# → 1847 kcal | P: 168g | C: 142g | F: 58g + +# Last 7 days +crono export nutrition -r 7d +# → 2026-02-11: 1847 kcal | P: 168g | C: 142g | F: 58g +# → 2026-02-10: 2103 kcal | P: 155g | C: 200g | F: 72g +# → ... + +# JSON for scripting +crono export nutrition -r 7d --json +# → [{"date":"2026-02-11","calories":1847,"protein":168,"carbs":142,"fat":58}, ...] + +# Raw CSV +crono export nutrition -r 30d --csv +# → Date,Energy (kcal),Protein (g),Carbs (g),Fat (g),... +``` + +### Exercises + +```bash +# Today's exercises +crono export exercises +# → Running: 30 min, 350 kcal + +# Range as JSON +crono export exercises -r 7d --json +# → [{"date":"2026-02-11","time":"07:30 AM","exercise":"Running","minutes":30,"calories_burned":350}, ...] +``` + +### Biometrics + +```bash +# Today's biometrics +crono export biometrics +# → Weight: 212.5 lbs + +# Range +crono export biometrics -r 30d +# → 2026-02-11: Weight: 212.5 lbs +# → 2026-02-10: Weight: 212.7 lbs +# → 2026-02-09: Blood Pressure: 120/80 mmHg +# → ... + +# JSON +crono export biometrics --json +# → {"date":"2026-02-11","time":"08:00 AM","metric":"Weight","unit":"lbs","amount":212.5} +``` + +## Architecture + +### New Module: `src/cronometer/` + +This command introduces a direct HTTP client for Cronometer, separate from the Kernel automation layer. + +``` +src/cronometer/ +├── auth.ts # HTTP login flow (anticsrf → form login → GWT authenticate) +├── export.ts # Nonce generation + export fetching +└── parse.ts # CSV parsing for each export type +``` + +### Data Flow + +``` +CLI Command → Cronometer Auth → GWT Nonce → HTTP Export → CSV → Parse → Output +``` + +No Kernel.sh, no browser, no Playwright. Pure HTTP requests using stored Cronometer credentials. + +### Authentication Flow + +Cronometer uses a multi-step cookie-based authentication: + +1. **GET `/login/`** — Fetch the login page HTML, parse the `anticsrf` hidden input value. Establishes `JSESSIONID` cookie. +2. **POST `/login`** — Form submit with `username`, `password`, `anticsrf`. Returns JSON `{"success": true}` and sets `sesnonce` cookie. +3. **POST `/cronometer/app`** — GWT RPC `authenticate` call. Returns the numeric user ID. Requires GWT headers and serialized RPC body. + +All three steps share a cookie jar (`JSESSIONID` + `sesnonce`). + +### Export Flow + +For each export request: + +1. **POST `/cronometer/app`** — GWT RPC `generateAuthorizationToken` call. Returns a single-use 32-char hex nonce. Requires the `sesnonce` and user ID from auth. +2. **GET `/export?nonce={nonce}&generate={type}&start={start}&end={end}`** — Returns CSV data. + +Nonces are single-use — a new one must be generated per export request. + +### GWT RPC Details + +Cronometer is a GWT (Google Web Toolkit) app. GWT RPC calls require specific headers and a serialized body format. + +**Headers (all GWT POST requests):** + +``` +Content-Type: text/x-gwt-rpc; charset=UTF-8 +X-GWT-Module-Base: https://cronometer.com/cronometer/ +X-GWT-Permutation: +``` + +**GWT magic values:** + +| Value | Description | Changes on deploy? | +| ---------------- | ------------------------------------ | ------------------ | +| `GWTPermutation` | App permutation hash (header) | Yes | +| `GWTHeader` | Service interface hash (body prefix) | Yes | + +These values are extracted from the live Cronometer web app and break when Cronometer deploys updates. + +**Defaults are hardcoded.** Users can override via config or environment variables when they break: + +Config (`~/.config/crono/config.json`): + +```json +{ + "gwtPermutation": "7B121DC5483BF272B1BC1916DA9FA963", + "gwtHeader": "2D6A926E3729946302DC68073CB0D550" +} +``` + +Environment variables (take precedence over config): + +```bash +export CRONO_GWT_PERMUTATION= +export CRONO_GWT_HEADER= +``` + +### GWT RPC Bodies + +**Authenticate:** + +``` +7|0|5|https://cronometer.com/cronometer/|{GWTHeader}|com.cronometer.shared.rpc.CronometerService|authenticate|java.lang.Integer/3438268394|1|2|3|4|1|5|5|{tzOffset}| +``` + +Where `{tzOffset}` is the local timezone offset in minutes (e.g. `-300` for US Eastern). + +**Generate Authorization Token:** + +``` +7|0|8|https://cronometer.com/cronometer/|{GWTHeader}|com.cronometer.shared.rpc.CronometerService|generateAuthorizationToken|java.lang.String/2004016611|I|com.cronometer.shared.user.AuthScope/2065601159|{sesnonce}|1|2|3|4|4|5|6|6|7|8|{userid}|3600|7|2| +``` + +Where `{sesnonce}` is the session nonce cookie value and `{userid}` is the numeric user ID from authentication. + +## CSV Response Formats + +### Nutrition (`dailySummary`) + +Columns include Date, Energy (kcal), Protein (g), Carbs (g), Fat (g), and dozens of micronutrient columns. + +For plain text output, display only the key macros: calories, protein, carbs, fat. Full data available via `--csv` and `--json`. + +### Exercises + +| Column | Example | +| --------------- | -------------- | +| Day | 2026-02-11 | +| Time | 07:30 AM | +| Exercise | Running | +| Minutes | 30 | +| Calories Burned | 350 | +| Group | Cardiovascular | + +### Biometrics + +| Column | Example | +| ------ | ---------- | +| Day | 2026-02-11 | +| Time | 08:00 AM | +| Metric | Weight | +| Unit | lbs | +| Amount | 212.5 | + +Note: Blood pressure `Amount` may contain `/` (e.g. `120/80`). Handle as a string, not a number. + +## Error Handling + +| Error | User Message | +| --------------------------- | --------------------------------------------------------------------------------------- | +| Invalid type | "Unknown export type. Use: nutrition, exercises, biometrics" | +| Invalid date format | "Invalid date format. Use YYYY-MM-DD" | +| Invalid range format | "Invalid range format. Use '7d', '30d', or 'YYYY-MM-DD:YYYY-MM-DD'" | +| Both -d and -r given | "-d and -r are mutually exclusive" | +| Both --csv and --json given | "--csv and --json are mutually exclusive" | +| Missing credentials | "No Cronometer credentials found. Run: crono login" | +| Login failed | "Cronometer login failed. Check your credentials with: crono login" | +| GWT auth failed | "Cronometer API error. GWT values may be outdated. See docs for override instructions." | +| Export request failed | "Export failed: \" | +| No data for date range | "No \ data found for the requested dates" | + +## Implementation Notes + +### Dependencies + +- Node.js built-in `fetch` (Node 18+) or `undici` — no new HTTP dependency needed +- CSV parsing — minimal, can use a lightweight parser or hand-roll since the format is predictable + +### Cookie Jar + +Node's native `fetch` does not manage cookies automatically. The auth flow requires a cookie jar that persists `JSESSIONID` and `sesnonce` across requests. Options: + +- Manual cookie extraction from `set-cookie` response headers +- `undici` cookie jar +- `tough-cookie` library + +Prefer the lightest option that works. Manual extraction is fine given the small number of requests. + +### Session Reuse + +For a single `crono export` invocation, the session flow is: + +1. Authenticate once (3 HTTP requests) +2. Generate nonce + export (2 HTTP requests per type) + +If we later support exporting multiple types in one invocation, the auth session can be reused — just generate a new nonce per export. + +## Migration Path + +Once the export API is proven stable: + +1. **`crono weight`** → Migrate to use `biometrics` export, filter for `Metric == "Weight"` +2. **`crono diary`** → Migrate to use `dailySummary` export +3. Kernel dependency becomes optional (only needed for write commands like `quick-add`) + +This reduces startup time from ~10-15s (browser spin-up) to <1s (HTTP requests). + +## Future Enhancements + +- `crono export servings` — Individual food entries with full nutrient breakdown +- `crono export notes` — Daily notes +- `crono export --all` — Export all types at once +- Auto-detection of GWT magic values by scraping the Cronometer app HTML +- Session caching — persist the `sesnonce` cookie between invocations (14-day expiry) to skip re-authentication From 90cc3179a32380457dfaa97ec5f7ccca5162a22d Mon Sep 17 00:00:00 2001 From: milldr Date: Thu, 12 Feb 2026 12:27:26 -0500 Subject: [PATCH 2/3] Add export command with direct Cronometer HTTP API integration --- README.md | 57 ++++++++++ src/commands/export.ts | 170 +++++++++++++++++++++++++++++ src/config.ts | 2 + src/cronometer/auth.ts | 140 ++++++++++++++++++++++++ src/cronometer/export.ts | 137 +++++++++++++++++++++++ src/cronometer/parse.ts | 194 +++++++++++++++++++++++++++++++++ src/index.ts | 12 ++ tests/cronometer/auth.test.ts | 86 +++++++++++++++ tests/cronometer/parse.test.ts | 139 +++++++++++++++++++++++ tests/export.test.ts | 51 +++++++++ 10 files changed, 988 insertions(+) create mode 100644 src/commands/export.ts create mode 100644 src/cronometer/auth.ts create mode 100644 src/cronometer/export.ts create mode 100644 src/cronometer/parse.ts create mode 100644 tests/cronometer/auth.test.ts create mode 100644 tests/cronometer/parse.test.ts create mode 100644 tests/export.test.ts diff --git a/README.md b/README.md index f2f7bb2..b6eb661 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,63 @@ crono diary -r 7d --json # → [{"date":"2026-02-11","calories":1847,"protein":168,"carbs":142,"fat":58}, ...] ``` +### `crono export` + +Export data directly from Cronometer's API — no browser automation, much faster than `diary` or `weight`. + +```bash +crono export [options] +``` + +**Types:** + +| Type | Description | +| ------------ | ----------------------------------- | +| `nutrition` | Daily nutrition totals (macros) | +| `exercises` | Exercise entries with duration/cals | +| `biometrics` | Biometric measurements (weight, BP) | + +**Options:** + +| Flag | Long | Description | +| ---- | ----------------- | ----------------------------------------- | +| `-d` | `--date ` | Date (YYYY-MM-DD) | +| `-r` | `--range ` | Range (7d, 30d, or YYYY-MM-DD:YYYY-MM-DD) | +| | `--csv` | Output as raw CSV | +| | `--json` | Output as JSON | + +`-d` and `-r` are mutually exclusive. `--csv` and `--json` are mutually exclusive. + +**Examples:** + +```bash +# Today's nutrition +crono export nutrition +# → 1847 kcal | P: 168g | C: 142g | F: 58g + +# Last 7 days of nutrition as JSON +crono export nutrition -r 7d --json + +# Today's exercises +crono export exercises +# → Running: 30 min, 350 kcal + +# Biometrics for last 30 days +crono export biometrics -r 30d +# → 2026-02-11: Weight: 212.5 lbs +# → 2026-02-09: Blood Pressure: 120/80 mmHg + +# Raw CSV export +crono export nutrition -r 30d --csv +``` + +**GWT overrides:** If Cronometer updates break the export, override GWT values in `~/.config/crono/config.json` or via environment variables: + +```bash +export CRONO_GWT_PERMUTATION= +export CRONO_GWT_HEADER= +``` + ## Requirements - Node.js 18+ diff --git a/src/commands/export.ts b/src/commands/export.ts new file mode 100644 index 0000000..89cf196 --- /dev/null +++ b/src/commands/export.ts @@ -0,0 +1,170 @@ +import * as p from "@clack/prompts"; +import { exportData, type ExportType } from "../cronometer/export.js"; +import { + parseNutrition, + parseExercises, + parseBiometrics, + type NutritionEntry, + type ExerciseEntry, + type BiometricEntry, +} from "../cronometer/parse.js"; +import { parseDate, parseRange, todayStr } from "../utils/date.js"; + +const VALID_TYPES = ["nutrition", "exercises", "biometrics"] as const; + +export interface ExportOptions { + date?: string; + range?: string; + csv?: boolean; + json?: boolean; +} + +export async function exportCmd( + type: string, + options: ExportOptions +): Promise { + // Validate type + if (!VALID_TYPES.includes(type as ExportType)) { + p.log.error("Unknown export type. Use: nutrition, exercises, biometrics"); + process.exit(1); + } + + // Validate mutual exclusivity + if (options.date && options.range) { + p.log.error("-d and -r are mutually exclusive"); + process.exit(1); + } + + if (options.csv && options.json) { + p.log.error("--csv and --json are mutually exclusive"); + process.exit(1); + } + + // Resolve date range + let start: string; + let end: string; + let isRange = false; + + try { + if (options.range) { + isRange = true; + const range = parseRange(options.range); + start = range.start; + end = range.end; + } else if (options.date) { + start = parseDate(options.date); + end = start; + } else { + start = todayStr(); + end = start; + } + } catch (error) { + p.log.error(String(error instanceof Error ? error.message : error)); + process.exit(1); + } + + const silent = options.json || options.csv; + if (!silent) { + p.intro("🍎 crono export"); + } + + const s = silent ? null : p.spinner(); + s?.start("Connecting..."); + + try { + const csv = await exportData(type as ExportType, start, end, (msg) => + s?.message(msg) + ); + + s?.stop("Done."); + + // Raw CSV passthrough + if (options.csv) { + console.log(csv); + return; + } + + // Parse and output + const exportType = type as ExportType; + if (exportType === "nutrition") { + const entries = parseNutrition(csv); + if (entries.length === 0) { + if (!silent) p.outro("No nutrition data found for the requested dates"); + return; + } + if (options.json) { + console.log(JSON.stringify(isRange ? entries : entries[0], null, 2)); + } else { + formatNutrition(entries, isRange); + } + } else if (exportType === "exercises") { + const entries = parseExercises(csv); + if (entries.length === 0) { + if (!silent) p.outro("No exercises data found for the requested dates"); + return; + } + if (options.json) { + console.log(JSON.stringify(isRange ? entries : entries, null, 2)); + } else { + formatExercises(entries, isRange); + } + } else { + const entries = parseBiometrics(csv); + if (entries.length === 0) { + if (!silent) + p.outro("No biometrics data found for the requested dates"); + return; + } + if (options.json) { + console.log(JSON.stringify(isRange ? entries : entries, null, 2)); + } else { + formatBiometrics(entries, isRange); + } + } + } catch (error) { + s?.stop("Failed."); + p.log.error(`${error instanceof Error ? error.message : error}`); + process.exit(1); + } +} + +function formatNutrition(entries: NutritionEntry[], isRange: boolean): void { + if (!isRange) { + const e = entries[0]; + p.outro( + `${e.calories} kcal | P: ${e.protein}g | C: ${e.carbs}g | F: ${e.fat}g` + ); + return; + } + for (const e of entries) { + p.log.info( + `${e.date}: ${e.calories} kcal | P: ${e.protein}g | C: ${e.carbs}g | F: ${e.fat}g` + ); + } +} + +function formatExercises(entries: ExerciseEntry[], isRange: boolean): void { + if (!isRange) { + for (const e of entries) { + p.outro(`${e.exercise}: ${e.minutes} min, ${e.caloriesBurned} kcal`); + } + return; + } + for (const e of entries) { + p.log.info( + `${e.date}: ${e.exercise}: ${e.minutes} min, ${e.caloriesBurned} kcal` + ); + } +} + +function formatBiometrics(entries: BiometricEntry[], isRange: boolean): void { + if (!isRange) { + for (const e of entries) { + p.outro(`${e.metric}: ${e.amount} ${e.unit}`); + } + return; + } + for (const e of entries) { + p.log.info(`${e.date}: ${e.metric}: ${e.amount} ${e.unit}`); + } +} diff --git a/src/config.ts b/src/config.ts index fd7bb41..635b616 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,8 @@ import { join } from "path"; export interface CronoConfig { kernelProfile?: string; defaultMeal?: string; + gwtPermutation?: string; + gwtHeader?: string; } const CONFIG_DIR = join(homedir(), ".config", "crono"); diff --git a/src/cronometer/auth.ts b/src/cronometer/auth.ts new file mode 100644 index 0000000..37e6dde --- /dev/null +++ b/src/cronometer/auth.ts @@ -0,0 +1,140 @@ +/** + * Direct HTTP authentication with Cronometer. + * + * Three-step flow: + * 1. GET /login/ — parse anticsrf token and JSESSIONID cookie + * 2. POST /login — form-encoded login, get sesnonce cookie + * 3. POST /cronometer/app — GWT RPC authenticate, get user ID + */ + +const BASE_URL = "https://cronometer.com"; + +const DEFAULT_GWT_PERMUTATION = "7B121DC5483BF272B1BC1916DA9FA963"; +const DEFAULT_GWT_HEADER = "2D6A926E3729946302DC68073CB0D550"; + +export interface CronometerSession { + jsessionId: string; + sesnonce: string; + userId: number; +} + +/** Extract a named cookie value from a set-cookie header array. */ +export function extractCookie(headers: Headers, name: string): string | null { + const cookies = headers.getSetCookie(); + for (const cookie of cookies) { + const match = cookie.match(new RegExp(`${name}=([^;]+)`)); + if (match) return match[1]; + } + return null; +} + +/** Parse the anticsrf token from Cronometer's login page HTML. */ +export function parseAnticsrf(html: string): string | null { + const match = html.match(/name=["']anticsrf["']\s+value=["']([^"']+)["']/); + return match ? match[1] : null; +} + +/** Extract user ID from GWT authenticate response. */ +export function parseUserId(body: string): number | null { + // GWT response format: //OK[,...] + // The user ID is a large negative number in the response array + const match = body.match(/\/\/OK\[(-?\d+)/); + return match ? parseInt(match[1], 10) : null; +} + +/** Build GWT RPC body for the authenticate call. */ +export function buildAuthenticateBody(gwtHeader: string): string { + const tzOffset = new Date().getTimezoneOffset(); + return `7|0|5|https://cronometer.com/cronometer/|${gwtHeader}|com.cronometer.shared.rpc.CronometerService|authenticate|java.lang.Integer/3438268394|1|2|3|4|1|5|5|${tzOffset}|`; +} + +function gwtHeaders( + gwtPermutation: string, + cookies: string +): Record { + return { + "Content-Type": "text/x-gwt-rpc; charset=UTF-8", + "X-GWT-Module-Base": "https://cronometer.com/cronometer/", + "X-GWT-Permutation": gwtPermutation, + Cookie: cookies, + }; +} + +/** + * Authenticate with Cronometer via HTTP. + * Returns a session object with cookies and user ID. + */ +export async function login( + username: string, + password: string, + gwtPermutation?: string, + gwtHeader?: string +): Promise { + const permutation = gwtPermutation || DEFAULT_GWT_PERMUTATION; + const header = gwtHeader || DEFAULT_GWT_HEADER; + + // Step 1: GET /login/ — get anticsrf + JSESSIONID + const loginPageRes = await fetch(`${BASE_URL}/login/`, { + redirect: "manual", + }); + const loginPageHtml = await loginPageRes.text(); + + const anticsrf = parseAnticsrf(loginPageHtml); + if (!anticsrf) { + throw new Error("Failed to parse anticsrf token from login page"); + } + + const jsessionId = extractCookie(loginPageRes.headers, "JSESSIONID"); + if (!jsessionId) { + throw new Error("No JSESSIONID cookie received from login page"); + } + + // Step 2: POST /login — form login + const formBody = new URLSearchParams({ + username, + password, + anticsrf, + }); + + const loginRes = await fetch(`${BASE_URL}/login`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Cookie: `JSESSIONID=${jsessionId}`, + }, + body: formBody.toString(), + redirect: "manual", + }); + + const loginJson = (await loginRes.json()) as { success?: boolean }; + if (!loginJson.success) { + throw new Error( + "Cronometer login failed. Check your credentials with: crono login" + ); + } + + const sesnonce = extractCookie(loginRes.headers, "sesnonce"); + if (!sesnonce) { + throw new Error("No sesnonce cookie received after login"); + } + + // Step 3: POST /cronometer/app — GWT authenticate + const cookies = `JSESSIONID=${jsessionId}; sesnonce=${sesnonce}`; + const authBody = buildAuthenticateBody(header); + + const authRes = await fetch(`${BASE_URL}/cronometer/app`, { + method: "POST", + headers: gwtHeaders(permutation, cookies), + body: authBody, + }); + + const authText = await authRes.text(); + const userId = parseUserId(authText); + if (userId === null) { + throw new Error( + "Cronometer API error. GWT values may be outdated. See docs for override instructions." + ); + } + + return { jsessionId, sesnonce, userId }; +} diff --git a/src/cronometer/export.ts b/src/cronometer/export.ts new file mode 100644 index 0000000..256f295 --- /dev/null +++ b/src/cronometer/export.ts @@ -0,0 +1,137 @@ +/** + * Cronometer export API client. + * + * Generates a single-use nonce via GWT RPC, then fetches CSV data + * from the /export endpoint. No browser automation required. + */ + +import { getCredential } from "../credentials.js"; +import { loadConfig } from "../config.js"; +import { login, type CronometerSession } from "./auth.js"; + +const BASE_URL = "https://cronometer.com"; + +const DEFAULT_GWT_PERMUTATION = "7B121DC5483BF272B1BC1916DA9FA963"; +const DEFAULT_GWT_HEADER = "2D6A926E3729946302DC68073CB0D550"; + +export type ExportType = "nutrition" | "exercises" | "biometrics"; + +const EXPORT_TYPE_MAP: Record = { + nutrition: "dailySummary", + exercises: "exercises", + biometrics: "biometrics", +}; + +/** Build GWT RPC body for generateAuthorizationToken. */ +export function buildNonceBody( + gwtHeader: string, + sesnonce: string, + userId: number +): string { + return `7|0|8|https://cronometer.com/cronometer/|${gwtHeader}|com.cronometer.shared.rpc.CronometerService|generateAuthorizationToken|java.lang.String/2004016611|I|com.cronometer.shared.user.AuthScope/2065601159|${sesnonce}|1|2|3|4|4|5|6|6|7|8|${userId}|3600|7|2|`; +} + +/** Extract the nonce from a GWT RPC response. */ +export function parseNonce(body: string): string | null { + // Response format: //OK["<32-char-hex>",...] + const match = body.match(/\/\/OK\["([a-f0-9]+)"/); + return match ? match[1] : null; +} + +/** Generate a single-use authorization nonce. */ +export async function generateNonce( + session: CronometerSession, + gwtPermutation?: string, + gwtHeader?: string +): Promise { + const permutation = gwtPermutation || DEFAULT_GWT_PERMUTATION; + const header = gwtHeader || DEFAULT_GWT_HEADER; + const cookies = `JSESSIONID=${session.jsessionId}; sesnonce=${session.sesnonce}`; + + const body = buildNonceBody(header, session.sesnonce, session.userId); + + const res = await fetch(`${BASE_URL}/cronometer/app`, { + method: "POST", + headers: { + "Content-Type": "text/x-gwt-rpc; charset=UTF-8", + "X-GWT-Module-Base": "https://cronometer.com/cronometer/", + "X-GWT-Permutation": permutation, + Cookie: cookies, + }, + body, + }); + + const text = await res.text(); + const nonce = parseNonce(text); + if (!nonce) { + throw new Error( + "Cronometer API error. GWT values may be outdated. See docs for override instructions." + ); + } + + return nonce; +} + +/** Fetch CSV export data from Cronometer. */ +export async function fetchExport( + session: CronometerSession, + type: ExportType, + start: string, + end: string, + gwtPermutation?: string, + gwtHeader?: string +): Promise { + const nonce = await generateNonce(session, gwtPermutation, gwtHeader); + const generate = EXPORT_TYPE_MAP[type]; + const cookies = `JSESSIONID=${session.jsessionId}; sesnonce=${session.sesnonce}`; + + const url = `${BASE_URL}/export?nonce=${nonce}&generate=${generate}&start=${start}&end=${end}`; + const res = await fetch(url, { + headers: { Cookie: cookies }, + }); + + if (!res.ok) { + throw new Error(`Export failed: ${res.status}`); + } + + return res.text(); +} + +/** + * Top-level export orchestrator. + * Reads credentials, authenticates, fetches and returns raw CSV. + */ +export async function exportData( + type: ExportType, + start: string, + end: string, + onStatus?: (msg: string) => void +): Promise { + const username = getCredential("cronometer-username"); + const password = getCredential("cronometer-password"); + + if (!username || !password) { + throw new Error("No Cronometer credentials found. Run: crono login"); + } + + // Resolve GWT overrides: env vars > config > defaults + const config = loadConfig(); + const gwtPermutation = + process.env.CRONO_GWT_PERMUTATION || config.gwtPermutation; + const gwtHeader = process.env.CRONO_GWT_HEADER || config.gwtHeader; + + onStatus?.("Logging in..."); + const session = await login(username, password, gwtPermutation, gwtHeader); + + onStatus?.("Fetching data..."); + const csv = await fetchExport( + session, + type, + start, + end, + gwtPermutation, + gwtHeader + ); + + return csv; +} diff --git a/src/cronometer/parse.ts b/src/cronometer/parse.ts new file mode 100644 index 0000000..a744ac0 --- /dev/null +++ b/src/cronometer/parse.ts @@ -0,0 +1,194 @@ +/** + * CSV parsing for Cronometer export data. + * + * Hand-rolled parser — Cronometer's CSV format is simple and predictable. + * No external library needed. + */ + +export interface NutritionEntry { + date: string; + calories: number; + protein: number; + carbs: number; + fat: number; + [key: string]: string | number; +} + +export interface ExerciseEntry { + date: string; + time: string; + exercise: string; + minutes: number; + caloriesBurned: number; + group: string; +} + +export interface BiometricEntry { + date: string; + time: string; + metric: string; + unit: string; + amount: number | string; +} + +/** Parse a CSV string into rows of string arrays. Handles quoted fields. */ +export function parseCSV(csv: string): string[][] { + const lines = csv.trim().split("\n"); + return lines.map((line) => { + const fields: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (inQuotes) { + if (char === '"') { + if (i + 1 < line.length && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += char; + } + } else if (char === '"') { + inQuotes = true; + } else if (char === ",") { + fields.push(current); + current = ""; + } else { + current += char; + } + } + fields.push(current); + return fields; + }); +} + +/** Find the index of a column by name (case-insensitive). */ +function colIndex(headers: string[], name: string): number { + return headers.findIndex( + (h) => h.trim().toLowerCase() === name.toLowerCase() + ); +} + +/** Parse a numeric field, returning 0 if empty or NaN. */ +function num(value: string | undefined): number { + if (!value || value.trim() === "") return 0; + const n = parseFloat(value.trim()); + return isNaN(n) ? 0 : n; +} + +export function parseNutrition(csv: string): NutritionEntry[] { + const rows = parseCSV(csv); + if (rows.length < 2) return []; + + const headers = rows[0]; + const dateIdx = colIndex(headers, "Date"); + const energyIdx = colIndex(headers, "Energy (kcal)"); + const proteinIdx = colIndex(headers, "Protein (g)"); + const carbsIdx = colIndex(headers, "Carbs (g)"); + const fatIdx = colIndex(headers, "Fat (g)"); + + if (dateIdx === -1) return []; + + const entries: NutritionEntry[] = []; + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const date = row[dateIdx]?.trim(); + if (!date) continue; + + const entry: NutritionEntry = { + date, + calories: num(row[energyIdx]), + protein: num(row[proteinIdx]), + carbs: num(row[carbsIdx]), + fat: num(row[fatIdx]), + }; + + // Include all columns for JSON/CSV output + for (let j = 0; j < headers.length; j++) { + if (j === dateIdx) continue; + const key = headers[j].trim(); + const val = row[j]?.trim() ?? ""; + if (key && val !== "") { + const parsed = parseFloat(val); + entry[key] = isNaN(parsed) ? val : parsed; + } + } + + entries.push(entry); + } + + return entries; +} + +export function parseExercises(csv: string): ExerciseEntry[] { + const rows = parseCSV(csv); + if (rows.length < 2) return []; + + const headers = rows[0]; + const dayIdx = colIndex(headers, "Day"); + const timeIdx = colIndex(headers, "Time"); + const exerciseIdx = colIndex(headers, "Exercise"); + const minutesIdx = colIndex(headers, "Minutes"); + const caloriesIdx = colIndex(headers, "Calories Burned"); + const groupIdx = colIndex(headers, "Group"); + + if (dayIdx === -1) return []; + + const entries: ExerciseEntry[] = []; + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const date = row[dayIdx]?.trim(); + if (!date) continue; + + entries.push({ + date, + time: row[timeIdx]?.trim() ?? "", + exercise: row[exerciseIdx]?.trim() ?? "", + minutes: num(row[minutesIdx]), + caloriesBurned: num(row[caloriesIdx]), + group: row[groupIdx]?.trim() ?? "", + }); + } + + return entries; +} + +export function parseBiometrics(csv: string): BiometricEntry[] { + const rows = parseCSV(csv); + if (rows.length < 2) return []; + + const headers = rows[0]; + const dayIdx = colIndex(headers, "Day"); + const timeIdx = colIndex(headers, "Time"); + const metricIdx = colIndex(headers, "Metric"); + const unitIdx = colIndex(headers, "Unit"); + const amountIdx = colIndex(headers, "Amount"); + + if (dayIdx === -1) return []; + + const entries: BiometricEntry[] = []; + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const date = row[dayIdx]?.trim(); + if (!date) continue; + + const rawAmount = row[amountIdx]?.trim() ?? ""; + // Keep as string if it contains non-numeric chars (e.g. blood pressure "120/80") + const isNumeric = rawAmount !== "" && /^-?\d+(\.\d+)?$/.test(rawAmount); + const amount = isNumeric ? parseFloat(rawAmount) : rawAmount; + + entries.push({ + date, + time: row[timeIdx]?.trim() ?? "", + metric: row[metricIdx]?.trim() ?? "", + unit: row[unitIdx]?.trim() ?? "", + amount, + }); + } + + return entries; +} diff --git a/src/index.ts b/src/index.ts index 9c59099..6917b38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { login } from "./commands/login.js"; import { quickAdd } from "./commands/quick-add.js"; import { diary } from "./commands/diary.js"; import { weight } from "./commands/weight.js"; +import { exportCmd } from "./commands/export.js"; const program = new Command(); @@ -54,4 +55,15 @@ program await diary(options); }); +program + .command("export ") + .description("Export data from Cronometer (nutrition, exercises, biometrics)") + .option("-d, --date ", "Date (YYYY-MM-DD)") + .option("-r, --range ", "Range (7d, 30d, or YYYY-MM-DD:YYYY-MM-DD)") + .option("--csv", "Output as CSV") + .option("--json", "Output as JSON") + .action(async (type, options) => { + await exportCmd(type, options); + }); + program.parse(); diff --git a/tests/cronometer/auth.test.ts b/tests/cronometer/auth.test.ts new file mode 100644 index 0000000..afc81d9 --- /dev/null +++ b/tests/cronometer/auth.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { + parseAnticsrf, + parseUserId, + extractCookie, + buildAuthenticateBody, +} from "../../src/cronometer/auth.js"; + +describe("parseAnticsrf", () => { + it("should extract anticsrf from login page HTML", () => { + const html = `
`; + expect(parseAnticsrf(html)).toBe("abc123def456"); + }); + + it("should handle double-quoted attributes", () => { + const html = ``; + expect(parseAnticsrf(html)).toBe("token789"); + }); + + it("should handle single-quoted attributes", () => { + const html = ``; + expect(parseAnticsrf(html)).toBe("token456"); + }); + + it("should return null when no anticsrf found", () => { + const html = `
`; + expect(parseAnticsrf(html)).toBeNull(); + }); +}); + +describe("parseUserId", () => { + it("should extract user ID from GWT response", () => { + const body = '//OK[12345678,0,7,["com.cronometer"]]'; + expect(parseUserId(body)).toBe(12345678); + }); + + it("should handle negative user IDs", () => { + const body = "//OK[-98765432,0,7]"; + expect(parseUserId(body)).toBe(-98765432); + }); + + it("should return null for invalid response", () => { + expect(parseUserId("//EX[error]")).toBeNull(); + expect(parseUserId("invalid")).toBeNull(); + }); +}); + +describe("extractCookie", () => { + it("should extract a named cookie from headers", () => { + const headers = new Headers(); + headers.append("set-cookie", "JSESSIONID=abc123; Path=/; HttpOnly"); + expect(extractCookie(headers, "JSESSIONID")).toBe("abc123"); + }); + + it("should extract sesnonce cookie", () => { + const headers = new Headers(); + headers.append( + "set-cookie", + "sesnonce=deadbeef1234; Path=/; HttpOnly; Secure" + ); + expect(extractCookie(headers, "sesnonce")).toBe("deadbeef1234"); + }); + + it("should return null when cookie not found", () => { + const headers = new Headers(); + headers.append("set-cookie", "other=value; Path=/"); + expect(extractCookie(headers, "JSESSIONID")).toBeNull(); + }); +}); + +describe("buildAuthenticateBody", () => { + it("should construct valid GWT RPC body", () => { + const body = buildAuthenticateBody("HEADER123"); + expect(body).toContain("HEADER123"); + expect(body).toContain("com.cronometer.shared.rpc.CronometerService"); + expect(body).toContain("authenticate"); + expect(body).toContain("java.lang.Integer/3438268394"); + expect(body.startsWith("7|0|5|")).toBe(true); + }); + + it("should include timezone offset", () => { + const body = buildAuthenticateBody("HEADER123"); + const tzOffset = new Date().getTimezoneOffset(); + expect(body).toContain(`|${tzOffset}|`); + }); +}); diff --git a/tests/cronometer/parse.test.ts b/tests/cronometer/parse.test.ts new file mode 100644 index 0000000..6bb3aad --- /dev/null +++ b/tests/cronometer/parse.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { + parseCSV, + parseNutrition, + parseExercises, + parseBiometrics, +} from "../../src/cronometer/parse.js"; + +describe("parseCSV", () => { + it("should parse simple CSV", () => { + const csv = "a,b,c\n1,2,3"; + expect(parseCSV(csv)).toEqual([ + ["a", "b", "c"], + ["1", "2", "3"], + ]); + }); + + it("should handle quoted fields", () => { + const csv = 'a,"b,c",d\n1,"hello ""world""",3'; + expect(parseCSV(csv)).toEqual([ + ["a", "b,c", "d"], + ["1", 'hello "world"', "3"], + ]); + }); + + it("should handle empty fields", () => { + const csv = "a,,c\n1,,3"; + expect(parseCSV(csv)).toEqual([ + ["a", "", "c"], + ["1", "", "3"], + ]); + }); +}); + +describe("parseNutrition", () => { + const sampleCSV = `Date,Energy (kcal),Protein (g),Carbs (g),Fat (g) +2026-02-11,1847,168,142,58 +2026-02-10,2103,155,200,72`; + + it("should parse nutrition entries", () => { + const entries = parseNutrition(sampleCSV); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + date: "2026-02-11", + calories: 1847, + protein: 168, + carbs: 142, + fat: 58, + }); + expect(entries[1]).toMatchObject({ + date: "2026-02-10", + calories: 2103, + protein: 155, + carbs: 200, + fat: 72, + }); + }); + + it("should return empty array for empty CSV", () => { + expect(parseNutrition("")).toEqual([]); + expect(parseNutrition("Date,Energy (kcal)")).toEqual([]); + }); + + it("should handle missing values as 0", () => { + const csv = `Date,Energy (kcal),Protein (g),Carbs (g),Fat (g) +2026-02-11,,,,`; + const entries = parseNutrition(csv); + expect(entries[0]).toMatchObject({ + calories: 0, + protein: 0, + carbs: 0, + fat: 0, + }); + }); +}); + +describe("parseExercises", () => { + const sampleCSV = `Day,Time,Exercise,Minutes,Calories Burned,Group +2026-02-11,07:30 AM,Running,30,350,Cardiovascular +2026-02-11,06:00 PM,Weight Training,45,200,Strength`; + + it("should parse exercise entries", () => { + const entries = parseExercises(sampleCSV); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ + date: "2026-02-11", + time: "07:30 AM", + exercise: "Running", + minutes: 30, + caloriesBurned: 350, + group: "Cardiovascular", + }); + expect(entries[1]).toEqual({ + date: "2026-02-11", + time: "06:00 PM", + exercise: "Weight Training", + minutes: 45, + caloriesBurned: 200, + group: "Strength", + }); + }); + + it("should return empty array for empty CSV", () => { + expect(parseExercises("")).toEqual([]); + }); +}); + +describe("parseBiometrics", () => { + const sampleCSV = `Day,Time,Metric,Unit,Amount +2026-02-11,08:00 AM,Weight,lbs,212.5 +2026-02-09,09:00 AM,Blood Pressure,mmHg,120/80`; + + it("should parse biometric entries", () => { + const entries = parseBiometrics(sampleCSV); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ + date: "2026-02-11", + time: "08:00 AM", + metric: "Weight", + unit: "lbs", + amount: 212.5, + }); + }); + + it("should handle blood pressure as string", () => { + const entries = parseBiometrics(sampleCSV); + expect(entries[1]).toEqual({ + date: "2026-02-09", + time: "09:00 AM", + metric: "Blood Pressure", + unit: "mmHg", + amount: "120/80", + }); + }); + + it("should return empty array for empty CSV", () => { + expect(parseBiometrics("")).toEqual([]); + }); +}); diff --git a/tests/export.test.ts b/tests/export.test.ts new file mode 100644 index 0000000..726ff1a --- /dev/null +++ b/tests/export.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; + +describe("export command", () => { + it("should validate export type", () => { + const validTypes = ["nutrition", "exercises", "biometrics"]; + expect(validTypes.includes("nutrition")).toBe(true); + expect(validTypes.includes("exercises")).toBe(true); + expect(validTypes.includes("biometrics")).toBe(true); + expect(validTypes.includes("invalid")).toBe(false); + expect(validTypes.includes("food")).toBe(false); + }); + + it("should reject mutually exclusive -d and -r", () => { + const options = { date: "2026-02-10", range: "7d" }; + expect(options.date && options.range).toBeTruthy(); + }); + + it("should reject mutually exclusive --csv and --json", () => { + const options = { csv: true, json: true }; + expect(options.csv && options.json).toBeTruthy(); + }); + + it("should accept valid date format", () => { + const dateRe = /^\d{4}-\d{2}-\d{2}$/; + expect(dateRe.test("2026-02-10")).toBe(true); + expect(dateRe.test("02-10-2026")).toBe(false); + expect(dateRe.test("2026/02/10")).toBe(false); + }); + + it("should accept relative range formats", () => { + const rangeRe = /^(\d+)d$/; + expect(rangeRe.test("7d")).toBe(true); + expect(rangeRe.test("30d")).toBe(true); + expect(rangeRe.test("90d")).toBe(true); + expect(rangeRe.test("7days")).toBe(false); + }); + + it("should accept absolute range formats", () => { + const rangeRe = /^(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})$/; + expect(rangeRe.test("2026-01-15:2026-02-10")).toBe(true); + expect(rangeRe.test("2026-01-15")).toBe(false); + }); + + it("should default to today when no options provided", () => { + const options = {}; + const hasDate = "date" in options; + const hasRange = "range" in options; + expect(hasDate).toBe(false); + expect(hasRange).toBe(false); + }); +}); From db2b8fb464ef604cb4c6fc1d7d62a5875519bebf Mon Sep 17 00:00:00 2001 From: milldr Date: Thu, 12 Feb 2026 13:09:22 -0500 Subject: [PATCH 3/3] Fix auth cookie handling, sesnonce rotation, nonce parsing, and rate limit error --- src/cronometer/auth.ts | 70 +++++++++++++++++++++++++++------ src/cronometer/export.ts | 11 ++---- tests/cronometer/export.test.ts | 36 +++++++++++++++++ 3 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 tests/cronometer/export.test.ts diff --git a/src/cronometer/auth.ts b/src/cronometer/auth.ts index 37e6dde..6fdddd7 100644 --- a/src/cronometer/auth.ts +++ b/src/cronometer/auth.ts @@ -13,7 +13,7 @@ const DEFAULT_GWT_PERMUTATION = "7B121DC5483BF272B1BC1916DA9FA963"; const DEFAULT_GWT_HEADER = "2D6A926E3729946302DC68073CB0D550"; export interface CronometerSession { - jsessionId: string; + cookies: string; sesnonce: string; userId: number; } @@ -28,6 +28,29 @@ export function extractCookie(headers: Headers, name: string): string | null { return null; } +/** Collect all cookies from set-cookie headers as "name=value; name2=value2" string. */ +export function collectCookies(headers: Headers): string { + const cookies = headers.getSetCookie(); + const pairs: string[] = []; + for (const cookie of cookies) { + const match = cookie.match(/^([^=]+)=([^;]+)/); + if (match) pairs.push(`${match[1]}=${match[2]}`); + } + return pairs.join("; "); +} + +/** Merge two cookie strings, with later values overriding earlier ones. */ +export function mergeCookies(a: string, b: string): string { + const map = new Map(); + for (const str of [a, b]) { + for (const pair of str.split("; ")) { + const eq = pair.indexOf("="); + if (eq > 0) map.set(pair.substring(0, eq), pair.substring(eq + 1)); + } + } + return [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; "); +} + /** Parse the anticsrf token from Cronometer's login page HTML. */ export function parseAnticsrf(html: string): string | null { const match = html.match(/name=["']anticsrf["']\s+value=["']([^"']+)["']/); @@ -84,12 +107,13 @@ export async function login( throw new Error("Failed to parse anticsrf token from login page"); } + const step1Cookies = collectCookies(loginPageRes.headers); const jsessionId = extractCookie(loginPageRes.headers, "JSESSIONID"); if (!jsessionId) { throw new Error("No JSESSIONID cookie received from login page"); } - // Step 2: POST /login — form login + // Step 2: POST /login — form login (send all step 1 cookies including anticsrf cookie) const formBody = new URLSearchParams({ username, password, @@ -100,26 +124,43 @@ export async function login( method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", - Cookie: `JSESSIONID=${jsessionId}`, + Cookie: step1Cookies, }, body: formBody.toString(), redirect: "manual", }); - const loginJson = (await loginRes.json()) as { success?: boolean }; - if (!loginJson.success) { - throw new Error( - "Cronometer login failed. Check your credentials with: crono login" - ); - } - const sesnonce = extractCookie(loginRes.headers, "sesnonce"); + + // Check for login errors — parse body as JSON + const loginText = await loginRes.text(); + try { + const loginJson = JSON.parse(loginText) as { + error?: string; + }; + if (loginJson.error) { + throw new Error( + loginJson.error.includes("Too Many Attempts") + ? "Cronometer rate limit hit. Please wait a minute and try again." + : "Cronometer login failed. Check your credentials with: crono login" + ); + } + } catch (e) { + if (e instanceof Error && e.message.includes("Cronometer")) throw e; + // Non-JSON response (e.g. redirect body) — check sesnonce as success indicator + if (!sesnonce) { + throw new Error( + "Cronometer login failed. Check your credentials with: crono login" + ); + } + } if (!sesnonce) { throw new Error("No sesnonce cookie received after login"); } // Step 3: POST /cronometer/app — GWT authenticate - const cookies = `JSESSIONID=${jsessionId}; sesnonce=${sesnonce}`; + const step2Cookies = collectCookies(loginRes.headers); + const cookies = mergeCookies(step1Cookies, step2Cookies); const authBody = buildAuthenticateBody(header); const authRes = await fetch(`${BASE_URL}/cronometer/app`, { @@ -136,5 +177,10 @@ export async function login( ); } - return { jsessionId, sesnonce, userId }; + // Step 3 rotates the sesnonce — use the new one if present + const step3Cookies = collectCookies(authRes.headers); + const finalCookies = mergeCookies(cookies, step3Cookies); + const newSesnonce = extractCookie(authRes.headers, "sesnonce") || sesnonce; + + return { cookies: finalCookies, sesnonce: newSesnonce, userId }; } diff --git a/src/cronometer/export.ts b/src/cronometer/export.ts index 256f295..95c259f 100644 --- a/src/cronometer/export.ts +++ b/src/cronometer/export.ts @@ -33,8 +33,8 @@ export function buildNonceBody( /** Extract the nonce from a GWT RPC response. */ export function parseNonce(body: string): string | null { - // Response format: //OK["<32-char-hex>",...] - const match = body.match(/\/\/OK\["([a-f0-9]+)"/); + // Response format: //OK[1,["<32-char-hex>"],...] or //OK["<32-char-hex>",...] + const match = body.match(/"([a-f0-9]{32,})"/); return match ? match[1] : null; } @@ -46,8 +46,6 @@ export async function generateNonce( ): Promise { const permutation = gwtPermutation || DEFAULT_GWT_PERMUTATION; const header = gwtHeader || DEFAULT_GWT_HEADER; - const cookies = `JSESSIONID=${session.jsessionId}; sesnonce=${session.sesnonce}`; - const body = buildNonceBody(header, session.sesnonce, session.userId); const res = await fetch(`${BASE_URL}/cronometer/app`, { @@ -56,7 +54,7 @@ export async function generateNonce( "Content-Type": "text/x-gwt-rpc; charset=UTF-8", "X-GWT-Module-Base": "https://cronometer.com/cronometer/", "X-GWT-Permutation": permutation, - Cookie: cookies, + Cookie: session.cookies, }, body, }); @@ -83,11 +81,10 @@ export async function fetchExport( ): Promise { const nonce = await generateNonce(session, gwtPermutation, gwtHeader); const generate = EXPORT_TYPE_MAP[type]; - const cookies = `JSESSIONID=${session.jsessionId}; sesnonce=${session.sesnonce}`; const url = `${BASE_URL}/export?nonce=${nonce}&generate=${generate}&start=${start}&end=${end}`; const res = await fetch(url, { - headers: { Cookie: cookies }, + headers: { Cookie: session.cookies }, }); if (!res.ok) { diff --git a/tests/cronometer/export.test.ts b/tests/cronometer/export.test.ts new file mode 100644 index 0000000..ab6d2cb --- /dev/null +++ b/tests/cronometer/export.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { buildNonceBody, parseNonce } from "../../src/cronometer/export.js"; + +describe("buildNonceBody", () => { + it("should construct valid GWT RPC body", () => { + const body = buildNonceBody("HEADER123", "sesnonce456", 12345); + expect(body).toContain("HEADER123"); + expect(body).toContain("sesnonce456"); + expect(body).toContain("12345"); + expect(body).toContain("generateAuthorizationToken"); + expect(body).toContain("com.cronometer.shared.rpc.CronometerService"); + }); +}); + +describe("parseNonce", () => { + it('should extract nonce from //OK[1,["hex"]] format', () => { + const body = '//OK[1,["13405ce97180c39c7cabb6852736549c"],0,7]'; + expect(parseNonce(body)).toBe("13405ce97180c39c7cabb6852736549c"); + }); + + it('should extract nonce from //OK["hex"] format', () => { + const body = '//OK["abcdef0123456789abcdef0123456789"]'; + expect(parseNonce(body)).toBe("abcdef0123456789abcdef0123456789"); + }); + + it("should return null for error responses", () => { + const body = + '//EX[2,1,["com.cronometer.shared.user.exceptions.NotLoggedInException/844385496","Invalid or expired session"],0,7]'; + expect(parseNonce(body)).toBeNull(); + }); + + it("should return null for invalid responses", () => { + expect(parseNonce("invalid")).toBeNull(); + expect(parseNonce("")).toBeNull(); + }); +});