kahunas-cli 1.0.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -67
- package/dist/cli.js +310 -189
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,18 +13,12 @@ pnpm install
|
|
|
13
13
|
pnpm build
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
2)
|
|
16
|
+
2) Fetch data (browser login runs automatically on first run, headless by default):
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
pnpm kahunas
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
3) Fetch data:
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
pnpm kahunas -- checkins list
|
|
26
|
-
pnpm kahunas -- workout list
|
|
27
|
-
pnpm kahunas -- workout pick
|
|
19
|
+
pnpm kahunas checkins list
|
|
20
|
+
pnpm kahunas workout list
|
|
21
|
+
pnpm kahunas workout pick
|
|
28
22
|
```
|
|
29
23
|
|
|
30
24
|
You can also run without installing globally:
|
|
@@ -36,19 +30,6 @@ npx kahunas-cli workout events
|
|
|
36
30
|
|
|
37
31
|
## Commands
|
|
38
32
|
|
|
39
|
-
### Auth
|
|
40
|
-
|
|
41
|
-
- `kahunas auth login`
|
|
42
|
-
- Opens a browser, lets you log in, and saves the `auth-user-token`.
|
|
43
|
-
- `kahunas auth status`
|
|
44
|
-
- Checks whether the stored token is valid.
|
|
45
|
-
- `kahunas auth show`
|
|
46
|
-
- Prints the stored token.
|
|
47
|
-
|
|
48
|
-
Tokens are saved to:
|
|
49
|
-
|
|
50
|
-
- `~/.config/kahunas/config.json`
|
|
51
|
-
|
|
52
33
|
### Check-ins
|
|
53
34
|
|
|
54
35
|
- `kahunas checkins list`
|
|
@@ -74,88 +55,86 @@ Tokens are saved to:
|
|
|
74
55
|
If the API list is missing a program you see in the web UI, run:
|
|
75
56
|
|
|
76
57
|
```bash
|
|
77
|
-
pnpm kahunas
|
|
58
|
+
pnpm kahunas sync
|
|
78
59
|
```
|
|
79
60
|
|
|
80
|
-
|
|
61
|
+
Or:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm kahunas workout sync
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This runs a browser session (headless by default). You log in, then navigate to your workouts page. After you press Enter, the CLI captures the workout list from network responses and writes a cache:
|
|
81
68
|
|
|
82
69
|
- `~/.config/kahunas/workouts.json`
|
|
83
70
|
|
|
84
71
|
`workout list`, `workout pick`, and `workout latest` automatically merge the API list with this cache.
|
|
85
72
|
Raw output (`--raw`) prints the API response only.
|
|
86
73
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
To see when workouts happened, the calendar endpoint returns log events with timestamps. By default each event is summarized into a human-friendly structure (total volume sets, exercises, supersets). Use `--full` to return the full program payload (best effort; falls back to cached summary if needed).
|
|
74
|
+
If you add `~/.config/kahunas/auth.json`, the browser flow will attempt an automatic login and open your workouts page before capturing. Example:
|
|
90
75
|
|
|
91
|
-
```
|
|
92
|
-
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"email": "you@example.com",
|
|
79
|
+
"password": "your-password"
|
|
80
|
+
}
|
|
93
81
|
```
|
|
94
82
|
|
|
95
|
-
|
|
83
|
+
Keep this file private; it contains credentials.
|
|
96
84
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
Optional fields:
|
|
86
|
+
|
|
87
|
+
- `username` (use instead of `email`)
|
|
88
|
+
- `loginPath` (default: `/dashboard`)
|
|
100
89
|
|
|
101
|
-
|
|
90
|
+
If auto-capture does not find workouts, the CLI falls back to the manual prompt.
|
|
91
|
+
|
|
92
|
+
### Workout events (dates)
|
|
102
93
|
|
|
103
|
-
|
|
94
|
+
To see when workouts happened, the calendar endpoint returns log events with timestamps. The CLI returns the latest event summarized into a human-friendly structure (total volume sets, exercises, supersets). Use `--full` to return the full program payload (best effort; falls back to cached summary if needed).
|
|
104
95
|
|
|
105
96
|
```bash
|
|
106
|
-
pnpm kahunas
|
|
107
|
-
pnpm kahunas -- workout events --workout <workout-uuid>
|
|
97
|
+
pnpm kahunas workout events
|
|
108
98
|
```
|
|
109
99
|
|
|
110
|
-
Use `--minimal` to return the raw event objects without program enrichment. Use `--full`
|
|
100
|
+
Use `--minimal` to return the raw event objects without program enrichment. Use `--full` for full enriched output. Use `--debug-preview` to log where preview HTML was discovered (stderr only).
|
|
111
101
|
|
|
112
|
-
If the user UUID is missing, `workout events` will attempt to discover it from check-ins and save it.
|
|
113
|
-
|
|
114
|
-
- `KAHUNAS_USER_UUID=...`
|
|
115
|
-
- `--user <uuid>`
|
|
102
|
+
If the user UUID is missing, `workout events` will attempt to discover it from check-ins and save it.
|
|
116
103
|
|
|
117
104
|
### Workout preview server
|
|
118
105
|
|
|
119
106
|
Run a local dev server to preview workouts in a browser:
|
|
120
107
|
|
|
121
108
|
```bash
|
|
122
|
-
pnpm kahunas
|
|
109
|
+
pnpm kahunas serve
|
|
123
110
|
```
|
|
124
111
|
|
|
125
|
-
|
|
126
|
-
The JSON response matches the CLI output for `workout events --latest`, so there is only one data shape to maintain.
|
|
127
|
-
|
|
128
|
-
Options:
|
|
112
|
+
Or:
|
|
129
113
|
|
|
130
114
|
```bash
|
|
131
|
-
pnpm kahunas
|
|
132
|
-
pnpm kahunas -- workout serve --workout <workout-uuid>
|
|
133
|
-
pnpm kahunas -- workout serve --limit 3
|
|
115
|
+
pnpm kahunas workout serve
|
|
134
116
|
```
|
|
135
117
|
|
|
118
|
+
The HTML page is available at `http://127.0.0.1:3000` and the JSON endpoint is at `http://127.0.0.1:3000/api/workout`.
|
|
119
|
+
The JSON response matches the CLI output for `workout events`, so there is only one data shape to maintain.
|
|
120
|
+
|
|
136
121
|
Use `?day=<index>` to switch the selected workout day tab in the browser.
|
|
137
122
|
|
|
138
123
|
## Auto-login
|
|
139
124
|
|
|
140
|
-
Most commands auto-login
|
|
125
|
+
Most commands auto-login if a token is missing or expired. This runs a browser session and saves session details in `~/.config/kahunas/config.json` (headless by default). If `~/.config/kahunas/auth.json` is present, the login step is automated.
|
|
141
126
|
|
|
142
|
-
|
|
143
|
-
pnpm kahunas -- checkins list --no-auto-login
|
|
144
|
-
```
|
|
127
|
+
## Debug logging
|
|
145
128
|
|
|
146
|
-
|
|
129
|
+
Set `debug` to `true` in `~/.config/kahunas/config.json` to enable extra logs on stderr (includes workout preview debug output).
|
|
147
130
|
|
|
148
|
-
|
|
149
|
-
|
|
131
|
+
## Headless mode
|
|
132
|
+
|
|
133
|
+
Set `headless` to `false` in `~/.config/kahunas/config.json` to show the Playwright browser. Defaults to `true`.
|
|
150
134
|
|
|
151
|
-
##
|
|
135
|
+
## Flags
|
|
152
136
|
|
|
153
|
-
- `
|
|
154
|
-
- `KAHUNAS_CSRF`
|
|
155
|
-
- `KAHUNAS_CSRF_COOKIE`
|
|
156
|
-
- `KAHUNAS_COOKIE`
|
|
157
|
-
- `KAHUNAS_WEB_BASE_URL`
|
|
158
|
-
- `KAHUNAS_USER_UUID`
|
|
137
|
+
- `--raw` prints raw API responses (no formatting).
|
|
159
138
|
|
|
160
139
|
## Playwright
|
|
161
140
|
|
|
@@ -184,5 +163,4 @@ pnpm publish
|
|
|
184
163
|
## Notes
|
|
185
164
|
|
|
186
165
|
- This CLI uses the same APIs the web app uses; tokens can expire quickly.
|
|
187
|
-
-
|
|
188
|
-
- `workout events` relies on session cookies captured during `auth login`.
|
|
166
|
+
- Re-run any command (or `workout sync`) to refresh login when needed.
|
package/dist/cli.js
CHANGED
|
@@ -68,17 +68,13 @@ function isFlagEnabled(options, name) {
|
|
|
68
68
|
const value = options[name];
|
|
69
69
|
return value === "true" || value === "1" || value === "yes";
|
|
70
70
|
}
|
|
71
|
-
function shouldAutoLogin(options, defaultValue) {
|
|
72
|
-
if (isFlagEnabled(options, "auto-login")) return true;
|
|
73
|
-
if (isFlagEnabled(options, "no-auto-login")) return false;
|
|
74
|
-
return defaultValue;
|
|
75
|
-
}
|
|
76
71
|
|
|
77
72
|
//#endregion
|
|
78
73
|
//#region src/config.ts
|
|
79
74
|
const DEFAULT_BASE_URL = "https://api.kahunas.io";
|
|
80
75
|
const DEFAULT_WEB_BASE_URL = "https://kahunas.io";
|
|
81
76
|
const CONFIG_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "config.json");
|
|
77
|
+
const AUTH_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "auth.json");
|
|
82
78
|
const WORKOUT_CACHE_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "workouts.json");
|
|
83
79
|
function readConfig() {
|
|
84
80
|
if (!node_fs.existsSync(CONFIG_PATH)) return {};
|
|
@@ -89,6 +85,15 @@ function readConfig() {
|
|
|
89
85
|
throw new Error(`Invalid JSON in ${CONFIG_PATH}.`);
|
|
90
86
|
}
|
|
91
87
|
}
|
|
88
|
+
function readAuthConfig() {
|
|
89
|
+
if (!node_fs.existsSync(AUTH_PATH)) return;
|
|
90
|
+
const raw = node_fs.readFileSync(AUTH_PATH, "utf-8");
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error(`Invalid JSON in ${AUTH_PATH}.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
92
97
|
function writeConfig(config) {
|
|
93
98
|
const dir = node_path.dirname(CONFIG_PATH);
|
|
94
99
|
node_fs.mkdirSync(dir, { recursive: true });
|
|
@@ -113,26 +118,26 @@ function writeWorkoutCache(plans) {
|
|
|
113
118
|
node_fs.writeFileSync(WORKOUT_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, "utf-8");
|
|
114
119
|
return cache;
|
|
115
120
|
}
|
|
116
|
-
function resolveToken(
|
|
117
|
-
return
|
|
121
|
+
function resolveToken(_, config) {
|
|
122
|
+
return config.token;
|
|
118
123
|
}
|
|
119
|
-
function resolveCsrfToken(
|
|
120
|
-
return
|
|
124
|
+
function resolveCsrfToken(_, config) {
|
|
125
|
+
return config.csrfToken;
|
|
121
126
|
}
|
|
122
|
-
function resolveCsrfCookie(
|
|
123
|
-
return
|
|
127
|
+
function resolveCsrfCookie(_, config) {
|
|
128
|
+
return config.csrfCookie;
|
|
124
129
|
}
|
|
125
|
-
function resolveAuthCookie(
|
|
126
|
-
return
|
|
130
|
+
function resolveAuthCookie(_, config) {
|
|
131
|
+
return config.authCookie;
|
|
127
132
|
}
|
|
128
|
-
function resolveUserUuid(
|
|
129
|
-
return
|
|
133
|
+
function resolveUserUuid(_, config) {
|
|
134
|
+
return config.userUuid;
|
|
130
135
|
}
|
|
131
136
|
function resolveBaseUrl(options, config) {
|
|
132
|
-
return
|
|
137
|
+
return config.baseUrl ?? DEFAULT_BASE_URL;
|
|
133
138
|
}
|
|
134
139
|
function resolveWebBaseUrl(options, config) {
|
|
135
|
-
return
|
|
140
|
+
return config.webBaseUrl ?? DEFAULT_WEB_BASE_URL;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
//#endregion
|
|
@@ -172,6 +177,29 @@ function isLikelyLoginHtml(text) {
|
|
|
172
177
|
if (!trimmed.startsWith("<")) return false;
|
|
173
178
|
return trimmed.includes("login to your account") || trimmed.includes("welcome back") || trimmed.includes("<title>kahunas");
|
|
174
179
|
}
|
|
180
|
+
function extractJwtExpiry(token) {
|
|
181
|
+
const parts = token.split(".");
|
|
182
|
+
if (parts.length < 2) return;
|
|
183
|
+
const payload = decodeBase64Url(parts[1]);
|
|
184
|
+
if (!payload) return;
|
|
185
|
+
try {
|
|
186
|
+
const data = JSON.parse(payload);
|
|
187
|
+
if (typeof data.exp !== "number" || !Number.isFinite(data.exp)) return;
|
|
188
|
+
return (/* @__PURE__ */ new Date(data.exp * 1e3)).toISOString();
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function decodeBase64Url(value) {
|
|
194
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
195
|
+
const padding = normalized.length % 4;
|
|
196
|
+
const padded = padding === 0 ? normalized : normalized + "=".repeat(4 - padding);
|
|
197
|
+
try {
|
|
198
|
+
return Buffer.from(padded, "base64").toString("utf-8");
|
|
199
|
+
} catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
175
203
|
|
|
176
204
|
//#endregion
|
|
177
205
|
//#region src/http.ts
|
|
@@ -249,6 +277,20 @@ async function fetchAuthToken(csrfToken, cookieHeader, webBaseUrl) {
|
|
|
249
277
|
};
|
|
250
278
|
}
|
|
251
279
|
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/output.ts
|
|
282
|
+
function printResponse(response, rawOutput) {
|
|
283
|
+
if (rawOutput) {
|
|
284
|
+
console.log(response.text);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (response.json !== void 0) {
|
|
288
|
+
console.log(JSON.stringify(response.json, null, 2));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
console.log(response.text);
|
|
292
|
+
}
|
|
293
|
+
|
|
252
294
|
//#endregion
|
|
253
295
|
//#region src/responses.ts
|
|
254
296
|
function isTokenExpiredResponse(payload) {
|
|
@@ -273,12 +315,6 @@ function extractUserUuidFromCheckins(payload) {
|
|
|
273
315
|
|
|
274
316
|
//#endregion
|
|
275
317
|
//#region src/utils.ts
|
|
276
|
-
function parseNumber$1(value, fallback) {
|
|
277
|
-
if (!value) return fallback;
|
|
278
|
-
const parsed = Number.parseInt(value, 10);
|
|
279
|
-
if (Number.isNaN(parsed)) return fallback;
|
|
280
|
-
return parsed;
|
|
281
|
-
}
|
|
282
318
|
function askQuestion(prompt) {
|
|
283
319
|
return new Promise((resolve) => {
|
|
284
320
|
const rl = node_readline.createInterface({
|
|
@@ -294,6 +330,10 @@ function askQuestion(prompt) {
|
|
|
294
330
|
function waitForEnter(prompt) {
|
|
295
331
|
return askQuestion(prompt).then(() => void 0);
|
|
296
332
|
}
|
|
333
|
+
function debugLog(enabled, message) {
|
|
334
|
+
if (!enabled) return;
|
|
335
|
+
console.error(`[debug] ${message}`);
|
|
336
|
+
}
|
|
297
337
|
|
|
298
338
|
//#endregion
|
|
299
339
|
//#region src/workouts.ts
|
|
@@ -401,9 +441,178 @@ function buildWorkoutPlanIndex(plans) {
|
|
|
401
441
|
|
|
402
442
|
//#endregion
|
|
403
443
|
//#region src/auth.ts
|
|
444
|
+
const LOGIN_SELECTORS = {
|
|
445
|
+
username: [
|
|
446
|
+
"input[name=\"email\"]",
|
|
447
|
+
"input[id=\"email\"]",
|
|
448
|
+
"input[type=\"email\"]",
|
|
449
|
+
"input[autocomplete=\"email\"]",
|
|
450
|
+
"input[autocomplete=\"username\"]",
|
|
451
|
+
"input[name=\"username\"]",
|
|
452
|
+
"input[id=\"username\"]",
|
|
453
|
+
"input[placeholder*=\"email\" i]",
|
|
454
|
+
"input[placeholder*=\"username\" i]",
|
|
455
|
+
"input[type=\"text\"]"
|
|
456
|
+
],
|
|
457
|
+
password: [
|
|
458
|
+
"input[name=\"password\"]",
|
|
459
|
+
"input[id=\"password\"]",
|
|
460
|
+
"input[type=\"password\"]",
|
|
461
|
+
"input[autocomplete=\"current-password\"]"
|
|
462
|
+
],
|
|
463
|
+
submit: [
|
|
464
|
+
"button[type=\"submit\"]",
|
|
465
|
+
"input[type=\"submit\"]",
|
|
466
|
+
"button:has-text(\"Log in\")",
|
|
467
|
+
"button:has-text(\"Login\")",
|
|
468
|
+
"button:has-text(\"Sign in\")",
|
|
469
|
+
"button:has-text(\"Continue\")"
|
|
470
|
+
]
|
|
471
|
+
};
|
|
472
|
+
const PASSWORD_SELECTOR = LOGIN_SELECTORS.password.join(", ");
|
|
473
|
+
const WORKOUT_NAV_SELECTORS = [
|
|
474
|
+
"#client-workout_plan-view-button",
|
|
475
|
+
".select-client-action[data-action=\"workout_program\"]",
|
|
476
|
+
"[data-action=\"workout_program\"]"
|
|
477
|
+
];
|
|
478
|
+
const WORKOUT_NAV_QUERY_SELECTORS = [
|
|
479
|
+
"#client-workout_plan-view-button",
|
|
480
|
+
".select-client-action[data-action=\"workout_program\"]",
|
|
481
|
+
"[data-action=\"workout_program\"]",
|
|
482
|
+
"a.nav-link",
|
|
483
|
+
"button"
|
|
484
|
+
];
|
|
485
|
+
function normalizePath(pathname) {
|
|
486
|
+
if (pathname.startsWith("/")) return pathname;
|
|
487
|
+
return `/${pathname}`;
|
|
488
|
+
}
|
|
489
|
+
function resolveStoredAuth() {
|
|
490
|
+
const auth = readAuthConfig();
|
|
491
|
+
if (!auth) return;
|
|
492
|
+
const login = auth.username ?? auth.email;
|
|
493
|
+
if (!login || !auth.password) throw new Error(`Invalid auth.json at ${AUTH_PATH}. Expected \"username\" or \"email\" and \"password\".`);
|
|
494
|
+
return {
|
|
495
|
+
login,
|
|
496
|
+
password: auth.password,
|
|
497
|
+
loginPath: auth.loginPath
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
async function isSelectorVisible(page, selector) {
|
|
501
|
+
try {
|
|
502
|
+
return await page.isVisible(selector);
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function findVisibleSelector(page, selectors) {
|
|
508
|
+
for (const selector of selectors) if (await isSelectorVisible(page, selector)) return selector;
|
|
509
|
+
}
|
|
510
|
+
async function waitForAnyVisibleSelector(page, selectors, timeoutMs) {
|
|
511
|
+
const combined = selectors.join(", ");
|
|
512
|
+
try {
|
|
513
|
+
await page.waitForSelector(combined, {
|
|
514
|
+
state: "visible",
|
|
515
|
+
timeout: timeoutMs
|
|
516
|
+
});
|
|
517
|
+
return true;
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async function waitForAnySelectorMatch(page, selectors, timeoutMs) {
|
|
523
|
+
const started = Date.now();
|
|
524
|
+
while (Date.now() - started < timeoutMs) {
|
|
525
|
+
for (const selector of selectors) if (await page.$(selector)) return true;
|
|
526
|
+
await page.waitForTimeout(300);
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
async function waitForPasswordFieldGone(page, timeoutMs) {
|
|
531
|
+
const started = Date.now();
|
|
532
|
+
while (Date.now() - started < timeoutMs) {
|
|
533
|
+
if (!await isSelectorVisible(page, PASSWORD_SELECTOR)) return true;
|
|
534
|
+
await page.waitForTimeout(500);
|
|
535
|
+
}
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
async function attemptAutoLogin(page, auth, debug) {
|
|
539
|
+
if (!await waitForAnyVisibleSelector(page, LOGIN_SELECTORS.password, 5e3)) {
|
|
540
|
+
debugLog(debug, "Login form not detected; skipping auto-login.");
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
const usernameSelector = await findVisibleSelector(page, LOGIN_SELECTORS.username);
|
|
544
|
+
const passwordSelector = await findVisibleSelector(page, LOGIN_SELECTORS.password);
|
|
545
|
+
if (!passwordSelector) return false;
|
|
546
|
+
if (usernameSelector) await page.fill(usernameSelector, auth.login);
|
|
547
|
+
await page.fill(passwordSelector, auth.password);
|
|
548
|
+
const submitSelector = await findVisibleSelector(page, LOGIN_SELECTORS.submit);
|
|
549
|
+
if (submitSelector) await page.click(submitSelector);
|
|
550
|
+
else await page.press(passwordSelector, "Enter");
|
|
551
|
+
await page.waitForTimeout(1500);
|
|
552
|
+
debugLog(debug, "Submitted login form.");
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
async function waitForPlans(plans, timeoutMs) {
|
|
556
|
+
const started = Date.now();
|
|
557
|
+
while (Date.now() - started < timeoutMs) {
|
|
558
|
+
if (plans.length > 0) return true;
|
|
559
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
560
|
+
}
|
|
561
|
+
return plans.length > 0;
|
|
562
|
+
}
|
|
563
|
+
async function clickWorkoutNav(page, debug) {
|
|
564
|
+
for (const selector of WORKOUT_NAV_SELECTORS) {
|
|
565
|
+
const locator = page.locator(selector).first();
|
|
566
|
+
if (await locator.count() === 0) continue;
|
|
567
|
+
try {
|
|
568
|
+
await locator.scrollIntoViewIfNeeded();
|
|
569
|
+
} catch {}
|
|
570
|
+
try {
|
|
571
|
+
await locator.click({
|
|
572
|
+
timeout: 2e3,
|
|
573
|
+
force: true
|
|
574
|
+
});
|
|
575
|
+
debugLog(debug, `Clicked workout nav selector: ${selector}`);
|
|
576
|
+
return true;
|
|
577
|
+
} catch {}
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
return await page.evaluate((selectors) => {
|
|
581
|
+
const resolveCandidates = (sel) => Array.from(document.querySelectorAll(sel)).filter((node) => node instanceof HTMLElement);
|
|
582
|
+
const candidates = selectors.flatMap(resolveCandidates);
|
|
583
|
+
const byAction = candidates.find((node) => node.dataset.action === "workout_program");
|
|
584
|
+
const byText = candidates.find((node) => /workout/i.test(node.textContent ?? ""));
|
|
585
|
+
const target = byAction ?? byText;
|
|
586
|
+
if (!target) return false;
|
|
587
|
+
target.dispatchEvent(new MouseEvent("click", {
|
|
588
|
+
bubbles: true,
|
|
589
|
+
cancelable: true,
|
|
590
|
+
view: window
|
|
591
|
+
}));
|
|
592
|
+
target.click();
|
|
593
|
+
return true;
|
|
594
|
+
}, WORKOUT_NAV_QUERY_SELECTORS);
|
|
595
|
+
} catch {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async function triggerWorkoutCapture(page, webOrigin, plans, debug) {
|
|
600
|
+
if (plans.length > 0) return true;
|
|
601
|
+
if (!await waitForAnySelectorMatch(page, WORKOUT_NAV_SELECTORS, 12e3)) {
|
|
602
|
+
debugLog(debug, "Workout nav not detected.");
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
if (!await clickWorkoutNav(page, debug)) debugLog(debug, "Workout nav click failed.");
|
|
606
|
+
await page.waitForTimeout(1e3);
|
|
607
|
+
await waitForPlans(plans, 8e3);
|
|
608
|
+
debugLog(debug, `Workout capture plans=${plans.length}`);
|
|
609
|
+
return plans.length > 0;
|
|
610
|
+
}
|
|
404
611
|
async function captureWorkoutsFromBrowser(options, config) {
|
|
405
612
|
const webBaseUrl = resolveWebBaseUrl(options, config);
|
|
406
|
-
const headless =
|
|
613
|
+
const headless = config.headless ?? true;
|
|
614
|
+
const debug = config.debug === true;
|
|
615
|
+
const storedAuth = resolveStoredAuth();
|
|
407
616
|
const browser = await (await import("playwright")).chromium.launch({ headless });
|
|
408
617
|
const context = await browser.newContext();
|
|
409
618
|
const plans = [];
|
|
@@ -438,8 +647,14 @@ async function captureWorkoutsFromBrowser(options, config) {
|
|
|
438
647
|
try {
|
|
439
648
|
const page = await context.newPage();
|
|
440
649
|
const webOrigin = new URL(webBaseUrl).origin;
|
|
441
|
-
|
|
442
|
-
await
|
|
650
|
+
const startPath = storedAuth?.loginPath ? normalizePath(storedAuth.loginPath) : "/dashboard";
|
|
651
|
+
await page.goto(new URL(startPath, webOrigin).toString(), { waitUntil: "domcontentloaded" });
|
|
652
|
+
debugLog(debug, `Opened ${startPath}`);
|
|
653
|
+
if (storedAuth) {
|
|
654
|
+
debugLog(debug, "auth.json detected; attempting auto-login.");
|
|
655
|
+
if (await attemptAutoLogin(page, storedAuth, debug)) await waitForPasswordFieldGone(page, 15e3);
|
|
656
|
+
if (!await triggerWorkoutCapture(page, webOrigin, plans, debug)) await waitForEnter("Log in, open your workouts page, then press Enter to capture...");
|
|
657
|
+
} else await waitForEnter("Log in, open your workouts page, then press Enter to capture...");
|
|
443
658
|
const cookies = await context.cookies(webOrigin);
|
|
444
659
|
cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
|
445
660
|
csrfCookie = cookies.find((cookie) => cookie.name === "csrf_kahunas_cookie_token")?.value;
|
|
@@ -459,7 +674,9 @@ async function captureWorkoutsFromBrowser(options, config) {
|
|
|
459
674
|
}
|
|
460
675
|
async function loginWithBrowser(options, config) {
|
|
461
676
|
const webBaseUrl = resolveWebBaseUrl(options, config);
|
|
462
|
-
const headless =
|
|
677
|
+
const headless = config.headless ?? true;
|
|
678
|
+
const debug = config.debug === true;
|
|
679
|
+
const storedAuth = resolveStoredAuth();
|
|
463
680
|
const browser = await (await import("playwright")).chromium.launch({ headless });
|
|
464
681
|
const context = await browser.newContext();
|
|
465
682
|
let observedToken;
|
|
@@ -473,8 +690,15 @@ async function loginWithBrowser(options, config) {
|
|
|
473
690
|
try {
|
|
474
691
|
const page = await context.newPage();
|
|
475
692
|
const webOrigin = new URL(webBaseUrl).origin;
|
|
476
|
-
|
|
477
|
-
await
|
|
693
|
+
const startPath = storedAuth?.loginPath ? normalizePath(storedAuth.loginPath) : "/dashboard";
|
|
694
|
+
await page.goto(new URL(startPath, webOrigin).toString(), { waitUntil: "domcontentloaded" });
|
|
695
|
+
debugLog(debug, `Opened ${startPath}`);
|
|
696
|
+
if (storedAuth) {
|
|
697
|
+
debugLog(debug, "auth.json detected; attempting auto-login.");
|
|
698
|
+
if (await attemptAutoLogin(page, storedAuth, debug)) {
|
|
699
|
+
if (!await waitForPasswordFieldGone(page, 15e3)) await waitForEnter("Finish logging in, then press Enter to continue...");
|
|
700
|
+
}
|
|
701
|
+
} else await waitForEnter("Finish logging in, then press Enter to continue...");
|
|
478
702
|
if (!observedToken) {
|
|
479
703
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
480
704
|
await page.waitForTimeout(1500);
|
|
@@ -495,7 +719,7 @@ async function loginWithBrowser(options, config) {
|
|
|
495
719
|
const csrfToken = csrfCookie ?? resolveCsrfToken(options, config);
|
|
496
720
|
let raw;
|
|
497
721
|
if (!observedToken) {
|
|
498
|
-
if (!csrfToken) throw new Error("Missing CSRF token after login. Try again or
|
|
722
|
+
if (!csrfToken) throw new Error("Missing CSRF token after login. Try again or run 'kahunas workout sync'.");
|
|
499
723
|
if (!cookieHeader) throw new Error("Missing cookies after login. Try again.");
|
|
500
724
|
const { token: extractedToken, raw: fetchedRaw } = await fetchAuthToken(csrfToken, cookieHeader, webBaseUrl);
|
|
501
725
|
recordToken(extractedToken);
|
|
@@ -516,10 +740,14 @@ async function loginWithBrowser(options, config) {
|
|
|
516
740
|
}
|
|
517
741
|
async function loginAndPersist(options, config, outputMode) {
|
|
518
742
|
const result = await loginWithBrowser(options, config);
|
|
743
|
+
const tokenUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
744
|
+
const tokenExpiresAt = extractJwtExpiry(result.token) ?? null;
|
|
519
745
|
const nextConfig = {
|
|
520
746
|
...config,
|
|
521
747
|
token: result.token,
|
|
522
|
-
webBaseUrl: result.webBaseUrl
|
|
748
|
+
webBaseUrl: result.webBaseUrl,
|
|
749
|
+
tokenUpdatedAt,
|
|
750
|
+
tokenExpiresAt
|
|
523
751
|
};
|
|
524
752
|
if (result.csrfToken) nextConfig.csrfToken = result.csrfToken;
|
|
525
753
|
if (result.cookieHeader) nextConfig.authCookie = result.cookieHeader;
|
|
@@ -533,120 +761,7 @@ async function loginAndPersist(options, config, outputMode) {
|
|
|
533
761
|
//#endregion
|
|
534
762
|
//#region src/usage.ts
|
|
535
763
|
function printUsage() {
|
|
536
|
-
console.log(`kahunas - CLI for Kahunas API\n\nUsage:\n kahunas
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
//#endregion
|
|
540
|
-
//#region src/commands/auth.ts
|
|
541
|
-
async function handleAuth(positionals, options) {
|
|
542
|
-
const action = positionals[0];
|
|
543
|
-
if (!action || action === "help") {
|
|
544
|
-
printUsage();
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
if (action === "set") {
|
|
548
|
-
const token = positionals[1] ?? options.token;
|
|
549
|
-
if (!token) throw new Error("Missing token for auth set.");
|
|
550
|
-
const config = readConfig();
|
|
551
|
-
const baseUrl = resolveBaseUrl(options, config);
|
|
552
|
-
const csrfToken = resolveCsrfToken(options, config);
|
|
553
|
-
const webBaseUrl = resolveWebBaseUrl(options, config);
|
|
554
|
-
const authCookie = resolveAuthCookie(options, config);
|
|
555
|
-
const csrfCookie = resolveCsrfCookie(options, config);
|
|
556
|
-
writeConfig({
|
|
557
|
-
...config,
|
|
558
|
-
token,
|
|
559
|
-
baseUrl,
|
|
560
|
-
csrfToken,
|
|
561
|
-
webBaseUrl,
|
|
562
|
-
authCookie,
|
|
563
|
-
csrfCookie
|
|
564
|
-
});
|
|
565
|
-
console.log(`Saved token to ${CONFIG_PATH}`);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
if (action === "token") {
|
|
569
|
-
const config = readConfig();
|
|
570
|
-
const csrfToken = resolveCsrfToken(options, config);
|
|
571
|
-
if (!csrfToken) throw new Error("Missing CSRF token. Provide --csrf or set KAHUNAS_CSRF.");
|
|
572
|
-
const webBaseUrl = resolveWebBaseUrl(options, config);
|
|
573
|
-
const authCookie = resolveAuthCookie(options, config);
|
|
574
|
-
const csrfCookie = resolveCsrfCookie(options, config);
|
|
575
|
-
const cookieHeader = authCookie ?? `csrf_kahunas_cookie_token=${csrfCookie ?? csrfToken}`;
|
|
576
|
-
const rawOutput = isFlagEnabled(options, "raw");
|
|
577
|
-
const { token: extractedToken, raw } = await fetchAuthToken(csrfToken, cookieHeader, webBaseUrl);
|
|
578
|
-
const token = extractedToken && isLikelyAuthToken(extractedToken) ? extractedToken : void 0;
|
|
579
|
-
if (rawOutput) {
|
|
580
|
-
console.log(raw);
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
if (!token) {
|
|
584
|
-
console.log(raw);
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
const nextConfig = {
|
|
588
|
-
...config,
|
|
589
|
-
token,
|
|
590
|
-
csrfToken,
|
|
591
|
-
webBaseUrl
|
|
592
|
-
};
|
|
593
|
-
if (authCookie) nextConfig.authCookie = authCookie;
|
|
594
|
-
if (csrfCookie) nextConfig.csrfCookie = csrfCookie;
|
|
595
|
-
writeConfig(nextConfig);
|
|
596
|
-
console.log(token);
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
if (action === "login") {
|
|
600
|
-
await loginAndPersist(options, readConfig(), isFlagEnabled(options, "raw") ? "raw" : "token");
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
if (action === "status") {
|
|
604
|
-
const config = readConfig();
|
|
605
|
-
const autoLogin = shouldAutoLogin(options, false);
|
|
606
|
-
let token = resolveToken(options, config);
|
|
607
|
-
if (!token) if (autoLogin) token = await loginAndPersist(options, config, "silent");
|
|
608
|
-
else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
|
|
609
|
-
const baseUrl = resolveBaseUrl(options, config);
|
|
610
|
-
let response = await postJson("/api/v2/checkin/list", token, baseUrl, {
|
|
611
|
-
page: 1,
|
|
612
|
-
rpp: 1
|
|
613
|
-
});
|
|
614
|
-
if (autoLogin && isTokenExpiredResponse(response.json)) {
|
|
615
|
-
token = await loginAndPersist(options, config, "silent");
|
|
616
|
-
response = await postJson("/api/v2/checkin/list", token, baseUrl, {
|
|
617
|
-
page: 1,
|
|
618
|
-
rpp: 1
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
|
|
622
|
-
if (response.json === void 0) {
|
|
623
|
-
console.log("unknown");
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
console.log(isTokenExpiredResponse(response.json) ? "expired" : "valid");
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
if (action === "show") {
|
|
630
|
-
const config = readConfig();
|
|
631
|
-
if (!config.token) throw new Error("No token saved. Use 'kahunas auth set <token>' or set KAHUNAS_TOKEN.");
|
|
632
|
-
console.log(config.token);
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
throw new Error(`Unknown auth action: ${action}`);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
//#endregion
|
|
639
|
-
//#region src/output.ts
|
|
640
|
-
function printResponse(response, rawOutput) {
|
|
641
|
-
if (rawOutput) {
|
|
642
|
-
console.log(response.text);
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
if (response.json !== void 0) {
|
|
646
|
-
console.log(JSON.stringify(response.json, null, 2));
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
console.log(response.text);
|
|
764
|
+
console.log(`kahunas - CLI for Kahunas API\n\nUsage:\n kahunas checkins list [--raw]\n kahunas workout list [--raw]\n kahunas workout pick [--raw]\n kahunas workout latest [--raw]\n kahunas workout events [--minimal] [--full] [--debug-preview] [--raw]\n kahunas workout serve\n kahunas serve\n kahunas workout sync\n kahunas sync\n kahunas workout program <id> [--raw]\n\nConfig:\n ${CONFIG_PATH}`);
|
|
650
765
|
}
|
|
651
766
|
|
|
652
767
|
//#endregion
|
|
@@ -659,19 +774,17 @@ async function handleCheckins(positionals, options) {
|
|
|
659
774
|
}
|
|
660
775
|
if (action !== "list") throw new Error(`Unknown checkins action: ${action}`);
|
|
661
776
|
const config = readConfig();
|
|
662
|
-
const autoLogin = shouldAutoLogin(options, true);
|
|
663
777
|
let token = resolveToken(options, config);
|
|
664
|
-
if (!token)
|
|
665
|
-
else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
|
|
778
|
+
if (!token) token = await loginAndPersist(options, config, "silent");
|
|
666
779
|
const baseUrl = resolveBaseUrl(options, config);
|
|
667
|
-
const page =
|
|
668
|
-
const rpp =
|
|
780
|
+
const page = 1;
|
|
781
|
+
const rpp = 12;
|
|
669
782
|
const rawOutput = isFlagEnabled(options, "raw");
|
|
670
783
|
let response = await postJson("/api/v2/checkin/list", token, baseUrl, {
|
|
671
784
|
page,
|
|
672
785
|
rpp
|
|
673
786
|
});
|
|
674
|
-
if (
|
|
787
|
+
if (isTokenExpiredResponse(response.json)) {
|
|
675
788
|
token = await loginAndPersist(options, config, "silent");
|
|
676
789
|
response = await postJson("/api/v2/checkin/list", token, baseUrl, {
|
|
677
790
|
page,
|
|
@@ -1822,16 +1935,15 @@ async function handleWorkout(positionals, options) {
|
|
|
1822
1935
|
return;
|
|
1823
1936
|
}
|
|
1824
1937
|
const config = readConfig();
|
|
1825
|
-
const
|
|
1938
|
+
const debug = config.debug === true;
|
|
1939
|
+
const autoLogin = true;
|
|
1826
1940
|
let token = resolveToken(options, config);
|
|
1827
1941
|
const ensureToken = async () => {
|
|
1828
|
-
if (!token)
|
|
1829
|
-
else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
|
|
1942
|
+
if (!token) token = await loginAndPersist(options, config, "silent");
|
|
1830
1943
|
return token;
|
|
1831
1944
|
};
|
|
1832
1945
|
let webLoginInFlight = null;
|
|
1833
1946
|
const ensureWebLogin = async () => {
|
|
1834
|
-
if (!autoLogin) return;
|
|
1835
1947
|
if (!webLoginInFlight) webLoginInFlight = loginAndPersist(options, config, "silent").then(() => void 0).finally(() => {
|
|
1836
1948
|
webLoginInFlight = null;
|
|
1837
1949
|
});
|
|
@@ -1839,16 +1951,15 @@ async function handleWorkout(positionals, options) {
|
|
|
1839
1951
|
};
|
|
1840
1952
|
const baseUrl = resolveBaseUrl(options, config);
|
|
1841
1953
|
const rawOutput = isFlagEnabled(options, "raw");
|
|
1842
|
-
const page =
|
|
1843
|
-
const
|
|
1844
|
-
const listRpp = action === "latest" && options.rpp === void 0 ? 100 : rpp;
|
|
1954
|
+
const page = 1;
|
|
1955
|
+
const listRpp = action === "latest" ? 100 : 12;
|
|
1845
1956
|
const fetchList = async () => {
|
|
1846
1957
|
await ensureToken();
|
|
1847
1958
|
const url = new URL("/api/v1/workoutprogram", baseUrl);
|
|
1848
|
-
|
|
1959
|
+
url.searchParams.set("page", String(page));
|
|
1849
1960
|
if (listRpp) url.searchParams.set("rpp", String(listRpp));
|
|
1850
1961
|
let response = await getWithAuth(url.pathname + url.search, token, baseUrl);
|
|
1851
|
-
if (
|
|
1962
|
+
if (isTokenExpiredResponse(response.json)) {
|
|
1852
1963
|
token = await loginAndPersist(options, config, "silent");
|
|
1853
1964
|
response = await getWithAuth(url.pathname + url.search, token, baseUrl);
|
|
1854
1965
|
}
|
|
@@ -1864,7 +1975,7 @@ async function handleWorkout(positionals, options) {
|
|
|
1864
1975
|
const fetchWorkoutEventsPayload = async () => {
|
|
1865
1976
|
const baseWebUrl = resolveWebBaseUrl(options, config);
|
|
1866
1977
|
const webOrigin = new URL(baseWebUrl).origin;
|
|
1867
|
-
const timezone =
|
|
1978
|
+
const timezone = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "Europe/London";
|
|
1868
1979
|
let userUuid = resolveUserUuid(options, config);
|
|
1869
1980
|
if (!userUuid) try {
|
|
1870
1981
|
await ensureToken();
|
|
@@ -1872,7 +1983,7 @@ async function handleWorkout(positionals, options) {
|
|
|
1872
1983
|
page: 1,
|
|
1873
1984
|
rpp: 1
|
|
1874
1985
|
});
|
|
1875
|
-
if (
|
|
1986
|
+
if (isTokenExpiredResponse(checkinsResponse.json)) {
|
|
1876
1987
|
token = await loginAndPersist(options, config, "silent");
|
|
1877
1988
|
checkinsResponse = await postJson("/api/v2/checkin/list", token, baseUrl, {
|
|
1878
1989
|
page: 1,
|
|
@@ -1890,7 +2001,7 @@ async function handleWorkout(positionals, options) {
|
|
|
1890
2001
|
}
|
|
1891
2002
|
}
|
|
1892
2003
|
} catch {}
|
|
1893
|
-
if (!userUuid) throw new Error("Missing user uuid.
|
|
2004
|
+
if (!userUuid) throw new Error("Missing user uuid. Run 'kahunas checkins list' or 'kahunas workout sync' once.");
|
|
1894
2005
|
if (userUuid !== config.userUuid) writeConfig({
|
|
1895
2006
|
...config,
|
|
1896
2007
|
userUuid
|
|
@@ -1909,13 +2020,13 @@ async function handleWorkout(positionals, options) {
|
|
|
1909
2020
|
effectiveCsrfToken = csrfCookie ?? csrfToken$1;
|
|
1910
2021
|
cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
|
|
1911
2022
|
}
|
|
1912
|
-
if (!effectiveCsrfToken) throw new Error("Missing CSRF token. Run 'kahunas
|
|
1913
|
-
if (!cookieHeader) throw new Error("Missing cookies. Run 'kahunas
|
|
2023
|
+
if (!effectiveCsrfToken) throw new Error("Missing CSRF token. Run 'kahunas workout sync' and try again.");
|
|
2024
|
+
if (!cookieHeader) throw new Error("Missing cookies. Run 'kahunas workout sync' and try again.");
|
|
1914
2025
|
const url = new URL(`/coach/clients/calendar/getEvent/${userUuid}`, webOrigin);
|
|
1915
2026
|
url.searchParams.set("timezone", timezone);
|
|
1916
2027
|
const body = new URLSearchParams();
|
|
1917
2028
|
body.set("csrf_kahunas_token", effectiveCsrfToken);
|
|
1918
|
-
body.set("filter",
|
|
2029
|
+
body.set("filter", "");
|
|
1919
2030
|
let response = await fetch(url.toString(), {
|
|
1920
2031
|
method: "POST",
|
|
1921
2032
|
headers: {
|
|
@@ -1930,7 +2041,7 @@ async function handleWorkout(positionals, options) {
|
|
|
1930
2041
|
});
|
|
1931
2042
|
let text = await response.text();
|
|
1932
2043
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`);
|
|
1933
|
-
if (
|
|
2044
|
+
if (isLikelyLoginHtml(text)) {
|
|
1934
2045
|
await ensureWebLogin();
|
|
1935
2046
|
const refreshed = readConfig();
|
|
1936
2047
|
csrfToken$1 = resolveCsrfToken(options, refreshed);
|
|
@@ -1938,7 +2049,7 @@ async function handleWorkout(positionals, options) {
|
|
|
1938
2049
|
authCookie = resolveAuthCookie(options, refreshed);
|
|
1939
2050
|
effectiveCsrfToken = csrfCookie ?? csrfToken$1;
|
|
1940
2051
|
cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
|
|
1941
|
-
if (!effectiveCsrfToken || !cookieHeader) throw new Error("Login required. Run 'kahunas
|
|
2052
|
+
if (!effectiveCsrfToken || !cookieHeader) throw new Error("Login required. Run 'kahunas workout sync' and try again.");
|
|
1942
2053
|
const retry = await fetch(url.toString(), {
|
|
1943
2054
|
method: "POST",
|
|
1944
2055
|
headers: {
|
|
@@ -1970,7 +2081,7 @@ async function handleWorkout(positionals, options) {
|
|
|
1970
2081
|
listUrl.searchParams.set("page", "1");
|
|
1971
2082
|
listUrl.searchParams.set("rpp", "100");
|
|
1972
2083
|
let listResponse = await getWithAuth(listUrl.pathname + listUrl.search, token, baseUrl);
|
|
1973
|
-
if (
|
|
2084
|
+
if (isTokenExpiredResponse(listResponse.json)) {
|
|
1974
2085
|
token = await loginAndPersist(options, config, "silent");
|
|
1975
2086
|
listResponse = await getWithAuth(listUrl.pathname + listUrl.search, token, baseUrl);
|
|
1976
2087
|
}
|
|
@@ -1990,7 +2101,7 @@ async function handleWorkout(positionals, options) {
|
|
|
1990
2101
|
try {
|
|
1991
2102
|
await ensureToken();
|
|
1992
2103
|
let responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, programId$1, effectiveCsrfToken);
|
|
1993
|
-
if (
|
|
2104
|
+
if (isTokenExpiredResponse(responseProgram$1.json)) {
|
|
1994
2105
|
token = await loginAndPersist(options, config, "silent");
|
|
1995
2106
|
responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, programId$1, effectiveCsrfToken);
|
|
1996
2107
|
}
|
|
@@ -2068,7 +2179,7 @@ async function handleWorkout(positionals, options) {
|
|
|
2068
2179
|
if (!chosen.uuid) throw new Error("Selected workout is missing a uuid.");
|
|
2069
2180
|
const csrfToken$1 = resolveCsrfToken(options, config);
|
|
2070
2181
|
let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
|
|
2071
|
-
if (
|
|
2182
|
+
if (isTokenExpiredResponse(responseProgram$1.json)) {
|
|
2072
2183
|
token = await loginAndPersist(options, config, "silent");
|
|
2073
2184
|
responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
|
|
2074
2185
|
}
|
|
@@ -2084,7 +2195,7 @@ async function handleWorkout(positionals, options) {
|
|
|
2084
2195
|
if (!chosen || !chosen.uuid) throw new Error("Latest workout is missing a uuid.");
|
|
2085
2196
|
const csrfToken$1 = resolveCsrfToken(options, config);
|
|
2086
2197
|
let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
|
|
2087
|
-
if (
|
|
2198
|
+
if (isTokenExpiredResponse(responseProgram$1.json)) {
|
|
2088
2199
|
token = await loginAndPersist(options, config, "silent");
|
|
2089
2200
|
responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
|
|
2090
2201
|
}
|
|
@@ -2095,8 +2206,8 @@ async function handleWorkout(positionals, options) {
|
|
|
2095
2206
|
if (action === "events") {
|
|
2096
2207
|
const minimal = isFlagEnabled(options, "minimal");
|
|
2097
2208
|
const full = isFlagEnabled(options, "full");
|
|
2098
|
-
const debugPreview = isFlagEnabled(options, "debug-preview");
|
|
2099
|
-
const limit =
|
|
2209
|
+
const debugPreview = isFlagEnabled(options, "debug-preview") || debug;
|
|
2210
|
+
const limit = 1;
|
|
2100
2211
|
const { text, payload, timezone } = await fetchWorkoutEventsPayload();
|
|
2101
2212
|
if (rawOutput) {
|
|
2102
2213
|
console.log(text);
|
|
@@ -2106,7 +2217,7 @@ async function handleWorkout(positionals, options) {
|
|
|
2106
2217
|
console.log(text);
|
|
2107
2218
|
return;
|
|
2108
2219
|
}
|
|
2109
|
-
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload
|
|
2220
|
+
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
|
|
2110
2221
|
const limited = limit > 0 ? sorted.slice(-limit) : sorted;
|
|
2111
2222
|
if (minimal) {
|
|
2112
2223
|
console.log(JSON.stringify(limited, null, 2));
|
|
@@ -2121,7 +2232,7 @@ async function handleWorkout(positionals, options) {
|
|
|
2121
2232
|
const match = findWorkoutPreviewHtmlMatch(record) ?? (program ? findWorkoutPreviewHtmlMatch(program) : void 0);
|
|
2122
2233
|
const dayIndex = resolveWorkoutEventDayIndex(entry, program);
|
|
2123
2234
|
const source = match ? match.source : "not_found";
|
|
2124
|
-
|
|
2235
|
+
debugLog(true, `preview event=${eventId} program=${programUuid ?? "unknown"} day_index=${dayIndex ?? "none"} source=${source}`);
|
|
2125
2236
|
}
|
|
2126
2237
|
if (full) {
|
|
2127
2238
|
const enriched = enrichWorkoutEvents(limited, programDetails);
|
|
@@ -2130,27 +2241,27 @@ async function handleWorkout(positionals, options) {
|
|
|
2130
2241
|
}
|
|
2131
2242
|
const formatted = formatWorkoutEventsOutput(limited, programDetails, {
|
|
2132
2243
|
timezone,
|
|
2133
|
-
program:
|
|
2134
|
-
workout:
|
|
2244
|
+
program: void 0,
|
|
2245
|
+
workout: void 0
|
|
2135
2246
|
});
|
|
2136
2247
|
console.log(JSON.stringify(formatted, null, 2));
|
|
2137
2248
|
return;
|
|
2138
2249
|
}
|
|
2139
2250
|
if (action === "serve") {
|
|
2140
|
-
const host =
|
|
2141
|
-
const port =
|
|
2142
|
-
const limit =
|
|
2143
|
-
const cacheTtlMs =
|
|
2251
|
+
const host = "127.0.0.1";
|
|
2252
|
+
const port = 3e3;
|
|
2253
|
+
const limit = 1;
|
|
2254
|
+
const cacheTtlMs = 3e4;
|
|
2144
2255
|
const loadSummary = async () => {
|
|
2145
2256
|
const { text, payload, timezone } = await fetchWorkoutEventsPayload();
|
|
2146
2257
|
if (!Array.isArray(payload)) throw new Error(`Unexpected calendar response: ${text.slice(0, 200)}`);
|
|
2147
|
-
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload
|
|
2258
|
+
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
|
|
2148
2259
|
const bounded = limit > 0 ? sorted.slice(-limit) : sorted;
|
|
2149
2260
|
const programDetails = await buildProgramDetails(sorted);
|
|
2150
2261
|
const formatted = formatWorkoutEventsOutput(bounded, programDetails, {
|
|
2151
2262
|
timezone,
|
|
2152
|
-
program:
|
|
2153
|
-
workout:
|
|
2263
|
+
program: void 0,
|
|
2264
|
+
workout: void 0
|
|
2154
2265
|
});
|
|
2155
2266
|
const summary = formatted.events[0];
|
|
2156
2267
|
const programUuid = summary?.program?.uuid ?? (bounded[0] && typeof bounded[0] === "object" ? bounded[0].program : void 0);
|
|
@@ -2221,8 +2332,15 @@ async function handleWorkout(positionals, options) {
|
|
|
2221
2332
|
res.end(error instanceof Error ? error.message : "Server error");
|
|
2222
2333
|
}
|
|
2223
2334
|
}).listen(port, host, () => {
|
|
2335
|
+
const cache = readWorkoutCache();
|
|
2336
|
+
const freshConfig = readConfig();
|
|
2337
|
+
const lastSync = cache?.updatedAt ?? "none";
|
|
2338
|
+
const tokenExpiry = freshConfig.tokenExpiresAt ?? "unknown";
|
|
2224
2339
|
console.log(`Local workout server running at http://${host}:${port}`);
|
|
2225
2340
|
console.log(`JSON endpoint at http://${host}:${port}/api/workout`);
|
|
2341
|
+
console.log(`Config: ${CONFIG_PATH}`);
|
|
2342
|
+
console.log(`Last workout sync: ${lastSync}`);
|
|
2343
|
+
console.log(`Token expiry: ${tokenExpiry}`);
|
|
2226
2344
|
});
|
|
2227
2345
|
return;
|
|
2228
2346
|
}
|
|
@@ -2252,7 +2370,7 @@ async function handleWorkout(positionals, options) {
|
|
|
2252
2370
|
const ensuredToken = await ensureToken();
|
|
2253
2371
|
const csrfToken = resolveCsrfToken(options, config);
|
|
2254
2372
|
let responseProgram = await fetchWorkoutProgram(ensuredToken, baseUrl, programId, csrfToken);
|
|
2255
|
-
if (
|
|
2373
|
+
if (isTokenExpiredResponse(responseProgram.json)) {
|
|
2256
2374
|
token = await loginAndPersist(options, config, "silent");
|
|
2257
2375
|
responseProgram = await fetchWorkoutProgram(token, baseUrl, programId, csrfToken);
|
|
2258
2376
|
}
|
|
@@ -2271,12 +2389,15 @@ async function main() {
|
|
|
2271
2389
|
const command = positionals[0];
|
|
2272
2390
|
const rest = positionals.slice(1);
|
|
2273
2391
|
switch (command) {
|
|
2274
|
-
case "auth":
|
|
2275
|
-
await handleAuth(rest, options);
|
|
2276
|
-
return;
|
|
2277
2392
|
case "checkins":
|
|
2278
2393
|
await handleCheckins(rest, options);
|
|
2279
2394
|
return;
|
|
2395
|
+
case "sync":
|
|
2396
|
+
await handleWorkout(["sync", ...rest], options);
|
|
2397
|
+
return;
|
|
2398
|
+
case "serve":
|
|
2399
|
+
await handleWorkout(["serve", ...rest], options);
|
|
2400
|
+
return;
|
|
2280
2401
|
case "workout":
|
|
2281
2402
|
await handleWorkout(rest, options);
|
|
2282
2403
|
return;
|