kahunas-cli 1.0.6 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +15 -167
  2. package/dist/cli.js +470 -202
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,188 +1,36 @@
1
1
  # Kahunas CLI
2
2
 
3
- A TypeScript CLI for Kahunas (kahunas.io) to fetch check-ins and workouts.
4
-
5
- ![Kahunas CLI screenshot](https://unpkg.com/kahunas-cli@latest/ai-screenshot.png)
3
+ Fetch workouts from Kahunas and preview them locally.
6
4
 
7
5
  ## Quick start
8
6
 
9
- 1) Install dependencies and build:
7
+ 1) Install and build:
10
8
 
11
9
  ```bash
12
10
  pnpm install
13
11
  pnpm build
14
12
  ```
15
13
 
16
- 2) Log in once (opens a browser):
17
-
18
- ```bash
19
- pnpm kahunas -- auth login
20
- ```
21
-
22
- 3) Fetch data:
14
+ 2) Optional: add auto-login credentials at `~/.config/kahunas/auth.json`:
23
15
 
24
- ```bash
25
- pnpm kahunas -- checkins list
26
- pnpm kahunas -- workout list
27
- pnpm kahunas -- workout pick
28
- ```
29
-
30
- You can also run without installing globally:
31
-
32
- ```bash
33
- npx kahunas-cli checkins list
34
- npx kahunas-cli workout events
16
+ ```json
17
+ {
18
+ "email": "you@example.com",
19
+ "password": "your-password"
20
+ }
35
21
  ```
36
22
 
37
- ## Commands
38
-
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
- ### Check-ins
53
-
54
- - `kahunas checkins list`
55
- - Lists recent check-ins.
56
-
57
- ### Workouts
58
-
59
- - `kahunas workout list`
60
- - Lists workout programs.
61
- - `kahunas workout pick`
62
- - Shows a numbered list and lets you choose a program.
63
- - `kahunas workout latest`
64
- - Loads the most recently updated program.
65
- - `kahunas workout events`
66
- - Lists workout log events with dates and a human-friendly workout summary (from the calendar endpoint).
67
- - `kahunas workout serve`
68
- - Starts a local dev server with a workout preview page and a JSON endpoint that matches the CLI output.
69
- - `kahunas workout program <id>`
70
- - Fetches a program by UUID.
71
-
72
- ### Workout sync (browser capture)
73
-
74
- If the API list is missing a program you see in the web UI, run:
75
-
76
- ```bash
77
- pnpm kahunas -- workout sync
78
- ```
79
-
80
- This opens a browser, 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
-
82
- - `~/.config/kahunas/workouts.json`
83
-
84
- `workout list`, `workout pick`, and `workout latest` automatically merge the API list with this cache.
85
- Raw output (`--raw`) prints the API response only.
86
-
87
- ### Workout events (dates)
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).
90
-
91
- ```bash
92
- pnpm kahunas -- workout events --user <user-uuid>
93
- ```
94
-
95
- Or via pnpm:
96
-
97
- ```bash
98
- pnpm kahunas -- workout events
99
- ```
100
-
101
- Default timezone is `Europe/London`. Override with `--timezone`.
102
-
103
- You can filter by program or workout UUID:
23
+ 3) Sync workouts, then run the preview server:
104
24
 
105
25
  ```bash
106
- pnpm kahunas -- workout events --program <program-uuid>
107
- pnpm kahunas -- workout events --workout <workout-uuid>
26
+ pnpm kahunas sync
27
+ pnpm kahunas serve
108
28
  ```
109
29
 
110
- Use `--minimal` to return the raw event objects without program enrichment. Use `--full` to return the full enriched output. Use `--latest` for only the most recent event, or `--limit N` for the most recent N events. Use `--debug-preview` to log where preview HTML was discovered (stderr only).
30
+ Open `http://127.0.0.1:3000`.
111
31
 
112
- If the user UUID is missing, `workout events` will attempt to discover it from check-ins and save it. You can also set it directly:
113
-
114
- - `KAHUNAS_USER_UUID=...`
115
- - `--user <uuid>`
116
-
117
- ### Workout preview server
118
-
119
- Run a local dev server to preview workouts in a browser:
120
-
121
- ```bash
122
- pnpm kahunas -- workout serve
123
- ```
124
-
125
- 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`.
126
- The JSON response matches the CLI output for `workout events --latest`, so there is only one data shape to maintain.
127
-
128
- Options:
129
-
130
- ```bash
131
- pnpm kahunas -- workout serve --program <program-uuid>
132
- pnpm kahunas -- workout serve --workout <workout-uuid>
133
- pnpm kahunas -- workout serve --limit 3
134
- ```
135
-
136
- Use `?day=<index>` to switch the selected workout day tab in the browser.
137
-
138
- ## Auto-login
139
-
140
- Most commands auto-login by default if a token is missing or expired. To disable:
141
-
142
- ```bash
143
- pnpm kahunas -- checkins list --no-auto-login
144
- ```
145
-
146
- ## Flags
147
-
148
- - `--raw` prints raw API responses (no formatting).
149
- - `--headless` runs Playwright without a visible browser window.
150
-
151
- ## Environment variables
152
-
153
- - `KAHUNAS_TOKEN`
154
- - `KAHUNAS_CSRF`
155
- - `KAHUNAS_CSRF_COOKIE`
156
- - `KAHUNAS_COOKIE`
157
- - `KAHUNAS_WEB_BASE_URL`
158
- - `KAHUNAS_USER_UUID`
159
-
160
- ## Playwright
161
-
162
- If Playwright did not download a browser during install:
163
-
164
- ```bash
165
- pnpm exec playwright install chromium
166
- ```
167
-
168
- ## Testing
169
-
170
- Run the unit tests with:
171
-
172
- ```bash
173
- pnpm test
174
- ```
175
-
176
- ## Publishing
177
-
178
- The npm package is built from `dist/cli.js`. Publishing runs the build automatically:
179
-
180
- ```bash
181
- pnpm publish
182
- ```
32
+ If `auth.json` is missing, `sync` will prompt for credentials and save them.
183
33
 
184
- ## Notes
34
+ ## Advanced usage
185
35
 
186
- - This CLI uses the same APIs the web app uses; tokens can expire quickly.
187
- - `auth login` is the most reliable way to refresh the token.
188
- - `workout events` relies on session cookies captured during `auth login`.
36
+ See `docs/advanced.md` for all commands, flags, and configuration options.
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
9
10
  var __copyProps = (to, from, except, desc) => {
10
11
  if (from && typeof from === "object" || typeof from === "function") {
11
12
  for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
@@ -68,17 +69,13 @@ function isFlagEnabled(options, name) {
68
69
  const value = options[name];
69
70
  return value === "true" || value === "1" || value === "yes";
70
71
  }
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
72
 
77
73
  //#endregion
78
74
  //#region src/config.ts
79
75
  const DEFAULT_BASE_URL = "https://api.kahunas.io";
80
76
  const DEFAULT_WEB_BASE_URL = "https://kahunas.io";
81
77
  const CONFIG_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "config.json");
78
+ const AUTH_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "auth.json");
82
79
  const WORKOUT_CACHE_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "workouts.json");
83
80
  function readConfig() {
84
81
  if (!node_fs.existsSync(CONFIG_PATH)) return {};
@@ -89,6 +86,20 @@ function readConfig() {
89
86
  throw new Error(`Invalid JSON in ${CONFIG_PATH}.`);
90
87
  }
91
88
  }
89
+ function readAuthConfig() {
90
+ if (!node_fs.existsSync(AUTH_PATH)) return;
91
+ const raw = node_fs.readFileSync(AUTH_PATH, "utf-8");
92
+ try {
93
+ return JSON.parse(raw);
94
+ } catch {
95
+ throw new Error(`Invalid JSON in ${AUTH_PATH}.`);
96
+ }
97
+ }
98
+ function writeAuthConfig(auth) {
99
+ const dir = node_path.dirname(AUTH_PATH);
100
+ node_fs.mkdirSync(dir, { recursive: true });
101
+ node_fs.writeFileSync(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, "utf-8");
102
+ }
92
103
  function writeConfig(config) {
93
104
  const dir = node_path.dirname(CONFIG_PATH);
94
105
  node_fs.mkdirSync(dir, { recursive: true });
@@ -113,26 +124,26 @@ function writeWorkoutCache(plans) {
113
124
  node_fs.writeFileSync(WORKOUT_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, "utf-8");
114
125
  return cache;
115
126
  }
116
- function resolveToken(options, config) {
117
- return options.token ?? process.env.KAHUNAS_TOKEN ?? config.token;
127
+ function resolveToken(_, config) {
128
+ return config.token;
118
129
  }
119
- function resolveCsrfToken(options, config) {
120
- return options.csrf ?? process.env.KAHUNAS_CSRF ?? config.csrfToken;
130
+ function resolveCsrfToken(_, config) {
131
+ return config.csrfToken;
121
132
  }
122
- function resolveCsrfCookie(options, config) {
123
- return options["csrf-cookie"] ?? process.env.KAHUNAS_CSRF_COOKIE ?? config.csrfCookie;
133
+ function resolveCsrfCookie(_, config) {
134
+ return config.csrfCookie;
124
135
  }
125
- function resolveAuthCookie(options, config) {
126
- return options.cookie ?? process.env.KAHUNAS_COOKIE ?? config.authCookie;
136
+ function resolveAuthCookie(_, config) {
137
+ return config.authCookie;
127
138
  }
128
- function resolveUserUuid(options, config) {
129
- return options.user ?? process.env.KAHUNAS_USER_UUID ?? config.userUuid;
139
+ function resolveUserUuid(_, config) {
140
+ return config.userUuid;
130
141
  }
131
142
  function resolveBaseUrl(options, config) {
132
- return options["base-url"] ?? config.baseUrl ?? DEFAULT_BASE_URL;
143
+ return config.baseUrl ?? DEFAULT_BASE_URL;
133
144
  }
134
145
  function resolveWebBaseUrl(options, config) {
135
- return options["web-base-url"] ?? process.env.KAHUNAS_WEB_BASE_URL ?? config.webBaseUrl ?? DEFAULT_WEB_BASE_URL;
146
+ return config.webBaseUrl ?? DEFAULT_WEB_BASE_URL;
136
147
  }
137
148
 
138
149
  //#endregion
@@ -172,6 +183,37 @@ function isLikelyLoginHtml(text) {
172
183
  if (!trimmed.startsWith("<")) return false;
173
184
  return trimmed.includes("login to your account") || trimmed.includes("welcome back") || trimmed.includes("<title>kahunas");
174
185
  }
186
+ function extractJwtExpiry(token) {
187
+ const parts = token.split(".");
188
+ if (parts.length < 2) return;
189
+ const payload = decodeBase64Url(parts[1]);
190
+ if (!payload) return;
191
+ try {
192
+ const data = JSON.parse(payload);
193
+ if (typeof data.exp !== "number" || !Number.isFinite(data.exp)) return;
194
+ return (/* @__PURE__ */ new Date(data.exp * 1e3)).toISOString();
195
+ } catch {
196
+ return;
197
+ }
198
+ }
199
+ const DEFAULT_TOKEN_TTL_MS = 7200 * 1e3;
200
+ function resolveTokenExpiry(token, tokenUpdatedAt) {
201
+ const jwtExpiry = extractJwtExpiry(token);
202
+ if (jwtExpiry) return jwtExpiry;
203
+ const updatedAt = Date.parse(tokenUpdatedAt);
204
+ if (!Number.isFinite(updatedAt)) return;
205
+ return new Date(updatedAt + DEFAULT_TOKEN_TTL_MS).toISOString();
206
+ }
207
+ function decodeBase64Url(value) {
208
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
209
+ const padding = normalized.length % 4;
210
+ const padded = padding === 0 ? normalized : normalized + "=".repeat(4 - padding);
211
+ try {
212
+ return Buffer.from(padded, "base64").toString("utf-8");
213
+ } catch {
214
+ return;
215
+ }
216
+ }
175
217
 
176
218
  //#endregion
177
219
  //#region src/http.ts
@@ -249,6 +291,20 @@ async function fetchAuthToken(csrfToken, cookieHeader, webBaseUrl) {
249
291
  };
250
292
  }
251
293
 
294
+ //#endregion
295
+ //#region src/output.ts
296
+ function printResponse(response, rawOutput) {
297
+ if (rawOutput) {
298
+ console.log(response.text);
299
+ return;
300
+ }
301
+ if (response.json !== void 0) {
302
+ console.log(JSON.stringify(response.json, null, 2));
303
+ return;
304
+ }
305
+ console.log(response.text);
306
+ }
307
+
252
308
  //#endregion
253
309
  //#region src/responses.ts
254
310
  function isTokenExpiredResponse(payload) {
@@ -272,18 +328,107 @@ function extractUserUuidFromCheckins(payload) {
272
328
  }
273
329
 
274
330
  //#endregion
275
- //#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;
331
+ //#region node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js
332
+ var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
333
+ let p = process || {}, argv = p.argv || [], env = p.env || {};
334
+ let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
335
+ let formatter = (open, close, replace = open) => (input) => {
336
+ let string = "" + input, index = string.indexOf(close, open.length);
337
+ return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
338
+ };
339
+ let replaceClose = (string, close, replace, index) => {
340
+ let result = "", cursor = 0;
341
+ do {
342
+ result += string.substring(cursor, index) + replace;
343
+ cursor = index + close.length;
344
+ index = string.indexOf(close, cursor);
345
+ } while (~index);
346
+ return result + string.substring(cursor);
347
+ };
348
+ let createColors = (enabled = isColorSupported) => {
349
+ let f = enabled ? formatter : () => String;
350
+ return {
351
+ isColorSupported: enabled,
352
+ reset: f("\x1B[0m", "\x1B[0m"),
353
+ bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
354
+ dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
355
+ italic: f("\x1B[3m", "\x1B[23m"),
356
+ underline: f("\x1B[4m", "\x1B[24m"),
357
+ inverse: f("\x1B[7m", "\x1B[27m"),
358
+ hidden: f("\x1B[8m", "\x1B[28m"),
359
+ strikethrough: f("\x1B[9m", "\x1B[29m"),
360
+ black: f("\x1B[30m", "\x1B[39m"),
361
+ red: f("\x1B[31m", "\x1B[39m"),
362
+ green: f("\x1B[32m", "\x1B[39m"),
363
+ yellow: f("\x1B[33m", "\x1B[39m"),
364
+ blue: f("\x1B[34m", "\x1B[39m"),
365
+ magenta: f("\x1B[35m", "\x1B[39m"),
366
+ cyan: f("\x1B[36m", "\x1B[39m"),
367
+ white: f("\x1B[37m", "\x1B[39m"),
368
+ gray: f("\x1B[90m", "\x1B[39m"),
369
+ bgBlack: f("\x1B[40m", "\x1B[49m"),
370
+ bgRed: f("\x1B[41m", "\x1B[49m"),
371
+ bgGreen: f("\x1B[42m", "\x1B[49m"),
372
+ bgYellow: f("\x1B[43m", "\x1B[49m"),
373
+ bgBlue: f("\x1B[44m", "\x1B[49m"),
374
+ bgMagenta: f("\x1B[45m", "\x1B[49m"),
375
+ bgCyan: f("\x1B[46m", "\x1B[49m"),
376
+ bgWhite: f("\x1B[47m", "\x1B[49m"),
377
+ blackBright: f("\x1B[90m", "\x1B[39m"),
378
+ redBright: f("\x1B[91m", "\x1B[39m"),
379
+ greenBright: f("\x1B[92m", "\x1B[39m"),
380
+ yellowBright: f("\x1B[93m", "\x1B[39m"),
381
+ blueBright: f("\x1B[94m", "\x1B[39m"),
382
+ magentaBright: f("\x1B[95m", "\x1B[39m"),
383
+ cyanBright: f("\x1B[96m", "\x1B[39m"),
384
+ whiteBright: f("\x1B[97m", "\x1B[39m"),
385
+ bgBlackBright: f("\x1B[100m", "\x1B[49m"),
386
+ bgRedBright: f("\x1B[101m", "\x1B[49m"),
387
+ bgGreenBright: f("\x1B[102m", "\x1B[49m"),
388
+ bgYellowBright: f("\x1B[103m", "\x1B[49m"),
389
+ bgBlueBright: f("\x1B[104m", "\x1B[49m"),
390
+ bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
391
+ bgCyanBright: f("\x1B[106m", "\x1B[49m"),
392
+ bgWhiteBright: f("\x1B[107m", "\x1B[49m")
393
+ };
394
+ };
395
+ module.exports = createColors();
396
+ module.exports.createColors = createColors;
397
+ }));
398
+
399
+ //#endregion
400
+ //#region src/logger.ts
401
+ var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors());
402
+ function formatLabel(label, color) {
403
+ return import_picocolors.default.bold(color(label));
404
+ }
405
+ function logInfo(message) {
406
+ console.log(`${formatLabel("info", import_picocolors.default.cyan)} ${message}`);
407
+ }
408
+ function logError(message) {
409
+ console.error(`${formatLabel("error", import_picocolors.default.red)} ${message}`);
410
+ }
411
+ function logDebug(enabled, message) {
412
+ if (!enabled) return;
413
+ console.error(`${formatLabel("debug", import_picocolors.default.gray)} ${message}`);
281
414
  }
282
- function askQuestion(prompt) {
415
+ function logPlain(message) {
416
+ console.log(message);
417
+ }
418
+ function formatHeading(message) {
419
+ return import_picocolors.default.bold(message);
420
+ }
421
+ function formatDim(message) {
422
+ return import_picocolors.default.dim(message);
423
+ }
424
+
425
+ //#endregion
426
+ //#region src/utils.ts
427
+ function askQuestion(prompt, output = process.stdout) {
283
428
  return new Promise((resolve) => {
284
429
  const rl = node_readline.createInterface({
285
430
  input: process.stdin,
286
- output: process.stdout
431
+ output
287
432
  });
288
433
  rl.question(prompt, (answer) => {
289
434
  rl.close();
@@ -291,8 +436,11 @@ function askQuestion(prompt) {
291
436
  });
292
437
  });
293
438
  }
294
- function waitForEnter(prompt) {
295
- return askQuestion(prompt).then(() => void 0);
439
+ function waitForEnter(prompt, output = process.stdout) {
440
+ return askQuestion(prompt, output).then(() => void 0);
441
+ }
442
+ function debugLog(enabled, message) {
443
+ logDebug(enabled, message);
296
444
  }
297
445
 
298
446
  //#endregion
@@ -401,9 +549,181 @@ function buildWorkoutPlanIndex(plans) {
401
549
 
402
550
  //#endregion
403
551
  //#region src/auth.ts
552
+ const LOGIN_SELECTORS = {
553
+ username: [
554
+ "input[name=\"email\"]",
555
+ "input[id=\"email\"]",
556
+ "input[type=\"email\"]",
557
+ "input[autocomplete=\"email\"]",
558
+ "input[autocomplete=\"username\"]",
559
+ "input[name=\"username\"]",
560
+ "input[id=\"username\"]",
561
+ "input[placeholder*=\"email\" i]",
562
+ "input[placeholder*=\"username\" i]",
563
+ "input[type=\"text\"]"
564
+ ],
565
+ password: [
566
+ "input[name=\"password\"]",
567
+ "input[id=\"password\"]",
568
+ "input[type=\"password\"]",
569
+ "input[autocomplete=\"current-password\"]"
570
+ ],
571
+ submit: [
572
+ "button[type=\"submit\"]",
573
+ "input[type=\"submit\"]",
574
+ "button:has-text(\"Log in\")",
575
+ "button:has-text(\"Login\")",
576
+ "button:has-text(\"Sign in\")",
577
+ "button:has-text(\"Continue\")"
578
+ ]
579
+ };
580
+ const PASSWORD_SELECTOR = LOGIN_SELECTORS.password.join(", ");
581
+ const WORKOUT_NAV_SELECTORS = [
582
+ "#client-workout_plan-view-button",
583
+ ".select-client-action[data-action=\"workout_program\"]",
584
+ "[data-action=\"workout_program\"]"
585
+ ];
586
+ const WORKOUT_NAV_QUERY_SELECTORS = [
587
+ "#client-workout_plan-view-button",
588
+ ".select-client-action[data-action=\"workout_program\"]",
589
+ "[data-action=\"workout_program\"]",
590
+ "a.nav-link",
591
+ "button"
592
+ ];
593
+ function normalizePath(pathname) {
594
+ if (pathname.startsWith("/")) return pathname;
595
+ return `/${pathname}`;
596
+ }
597
+ function normalizeToken(token) {
598
+ return token.trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").replace(/^bearer\s+/i, "");
599
+ }
600
+ function resolveStoredAuth() {
601
+ const auth = readAuthConfig();
602
+ if (!auth) return;
603
+ const login = auth.username ?? auth.email;
604
+ if (!login || !auth.password) throw new Error(`Invalid auth.json at ${AUTH_PATH}. Expected \"username\" or \"email\" and \"password\".`);
605
+ return {
606
+ login,
607
+ password: auth.password,
608
+ loginPath: auth.loginPath
609
+ };
610
+ }
611
+ async function isSelectorVisible(page, selector) {
612
+ try {
613
+ return await page.isVisible(selector);
614
+ } catch {
615
+ return false;
616
+ }
617
+ }
618
+ async function findVisibleSelector(page, selectors) {
619
+ for (const selector of selectors) if (await isSelectorVisible(page, selector)) return selector;
620
+ }
621
+ async function waitForAnyVisibleSelector(page, selectors, timeoutMs) {
622
+ const combined = selectors.join(", ");
623
+ try {
624
+ await page.waitForSelector(combined, {
625
+ state: "visible",
626
+ timeout: timeoutMs
627
+ });
628
+ return true;
629
+ } catch {
630
+ return false;
631
+ }
632
+ }
633
+ async function waitForAnySelectorMatch(page, selectors, timeoutMs) {
634
+ const started = Date.now();
635
+ while (Date.now() - started < timeoutMs) {
636
+ for (const selector of selectors) if (await page.$(selector)) return true;
637
+ await page.waitForTimeout(300);
638
+ }
639
+ return false;
640
+ }
641
+ async function waitForPasswordFieldGone(page, timeoutMs) {
642
+ const started = Date.now();
643
+ while (Date.now() - started < timeoutMs) {
644
+ if (!await isSelectorVisible(page, PASSWORD_SELECTOR)) return true;
645
+ await page.waitForTimeout(500);
646
+ }
647
+ return false;
648
+ }
649
+ async function attemptAutoLogin(page, auth, debug) {
650
+ if (!await waitForAnyVisibleSelector(page, LOGIN_SELECTORS.password, 5e3)) {
651
+ debugLog(debug, "Login form not detected; skipping auto-login.");
652
+ return false;
653
+ }
654
+ const usernameSelector = await findVisibleSelector(page, LOGIN_SELECTORS.username);
655
+ const passwordSelector = await findVisibleSelector(page, LOGIN_SELECTORS.password);
656
+ if (!passwordSelector) return false;
657
+ if (usernameSelector) await page.fill(usernameSelector, auth.login);
658
+ await page.fill(passwordSelector, auth.password);
659
+ const submitSelector = await findVisibleSelector(page, LOGIN_SELECTORS.submit);
660
+ if (submitSelector) await page.click(submitSelector);
661
+ else await page.press(passwordSelector, "Enter");
662
+ await page.waitForTimeout(1500);
663
+ debugLog(debug, "Submitted login form.");
664
+ return true;
665
+ }
666
+ async function waitForPlans(plans, timeoutMs) {
667
+ const started = Date.now();
668
+ while (Date.now() - started < timeoutMs) {
669
+ if (plans.length > 0) return true;
670
+ await new Promise((resolve) => setTimeout(resolve, 250));
671
+ }
672
+ return plans.length > 0;
673
+ }
674
+ async function clickWorkoutNav(page, debug) {
675
+ for (const selector of WORKOUT_NAV_SELECTORS) {
676
+ const locator = page.locator(selector).first();
677
+ if (await locator.count() === 0) continue;
678
+ try {
679
+ await locator.scrollIntoViewIfNeeded();
680
+ } catch {}
681
+ try {
682
+ await locator.click({
683
+ timeout: 2e3,
684
+ force: true
685
+ });
686
+ debugLog(debug, `Clicked workout nav selector: ${selector}`);
687
+ return true;
688
+ } catch {}
689
+ }
690
+ try {
691
+ return await page.evaluate((selectors) => {
692
+ const resolveCandidates = (sel) => Array.from(document.querySelectorAll(sel)).filter((node) => node instanceof HTMLElement);
693
+ const candidates = selectors.flatMap(resolveCandidates);
694
+ const byAction = candidates.find((node) => node.dataset.action === "workout_program");
695
+ const byText = candidates.find((node) => /workout/i.test(node.textContent ?? ""));
696
+ const target = byAction ?? byText;
697
+ if (!target) return false;
698
+ target.dispatchEvent(new MouseEvent("click", {
699
+ bubbles: true,
700
+ cancelable: true,
701
+ view: window
702
+ }));
703
+ target.click();
704
+ return true;
705
+ }, WORKOUT_NAV_QUERY_SELECTORS);
706
+ } catch {
707
+ return false;
708
+ }
709
+ }
710
+ async function triggerWorkoutCapture(page, webOrigin, plans, debug) {
711
+ if (plans.length > 0) return true;
712
+ if (!await waitForAnySelectorMatch(page, WORKOUT_NAV_SELECTORS, 12e3)) {
713
+ debugLog(debug, "Workout nav not detected.");
714
+ return false;
715
+ }
716
+ if (!await clickWorkoutNav(page, debug)) debugLog(debug, "Workout nav click failed.");
717
+ await page.waitForTimeout(1e3);
718
+ await waitForPlans(plans, 8e3);
719
+ debugLog(debug, `Workout capture plans=${plans.length}`);
720
+ return plans.length > 0;
721
+ }
404
722
  async function captureWorkoutsFromBrowser(options, config) {
405
723
  const webBaseUrl = resolveWebBaseUrl(options, config);
406
- const headless = isFlagEnabled(options, "headless");
724
+ const headless = config.headless ?? true;
725
+ const debug = config.debug === true;
726
+ const storedAuth = resolveStoredAuth();
407
727
  const browser = await (await import("playwright")).chromium.launch({ headless });
408
728
  const context = await browser.newContext();
409
729
  const plans = [];
@@ -411,7 +731,8 @@ async function captureWorkoutsFromBrowser(options, config) {
411
731
  let observedToken;
412
732
  const recordToken = (candidate) => {
413
733
  if (!candidate || observedToken) return;
414
- if (isLikelyAuthToken(candidate)) observedToken = candidate;
734
+ const normalized = normalizeToken(candidate);
735
+ if (isLikelyAuthToken(normalized)) observedToken = normalized;
415
736
  };
416
737
  const recordPlans = (incoming) => {
417
738
  for (const plan of incoming) {
@@ -438,12 +759,25 @@ async function captureWorkoutsFromBrowser(options, config) {
438
759
  try {
439
760
  const page = await context.newPage();
440
761
  const webOrigin = new URL(webBaseUrl).origin;
441
- await page.goto(`${webOrigin}/dashboard`, { waitUntil: "domcontentloaded" });
442
- await waitForEnter("Log in, open your workouts page, then press Enter to capture...");
762
+ const startPath = storedAuth?.loginPath ? normalizePath(storedAuth.loginPath) : "/dashboard";
763
+ await page.goto(new URL(startPath, webOrigin).toString(), { waitUntil: "domcontentloaded" });
764
+ debugLog(debug, `Opened ${startPath}`);
765
+ if (storedAuth) {
766
+ debugLog(debug, "auth.json detected; attempting auto-login.");
767
+ if (await attemptAutoLogin(page, storedAuth, debug)) await waitForPasswordFieldGone(page, 15e3);
768
+ if (!await triggerWorkoutCapture(page, webOrigin, plans, debug)) await waitForEnter("Log in, open your workouts page, then press Enter to capture...");
769
+ } else await waitForEnter("Log in, open your workouts page, then press Enter to capture...");
443
770
  const cookies = await context.cookies(webOrigin);
444
771
  cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
445
772
  csrfCookie = cookies.find((cookie) => cookie.name === "csrf_kahunas_cookie_token")?.value;
446
773
  csrfToken = csrfCookie ?? resolveCsrfToken(options, config);
774
+ if (!observedToken && csrfToken && cookieHeader) try {
775
+ const { token: fetchedToken } = await fetchAuthToken(csrfToken, cookieHeader, webBaseUrl);
776
+ recordToken(fetchedToken);
777
+ debugLog(debug, "Fetched auth token via /get-token.");
778
+ } catch (error) {
779
+ debugLog(debug, `Failed to fetch auth token via /get-token: ${error instanceof Error ? error.message : "unknown error"}`);
780
+ }
447
781
  if (plans.length === 0) await page.waitForTimeout(1500);
448
782
  } finally {
449
783
  await browser.close();
@@ -459,13 +793,16 @@ async function captureWorkoutsFromBrowser(options, config) {
459
793
  }
460
794
  async function loginWithBrowser(options, config) {
461
795
  const webBaseUrl = resolveWebBaseUrl(options, config);
462
- const headless = isFlagEnabled(options, "headless");
796
+ const headless = config.headless ?? true;
797
+ const debug = config.debug === true;
798
+ const storedAuth = resolveStoredAuth();
463
799
  const browser = await (await import("playwright")).chromium.launch({ headless });
464
800
  const context = await browser.newContext();
465
801
  let observedToken;
466
802
  const recordToken = (candidate) => {
467
803
  if (!candidate || observedToken) return;
468
- if (isLikelyAuthToken(candidate)) observedToken = candidate;
804
+ const normalized = normalizeToken(candidate);
805
+ if (isLikelyAuthToken(normalized)) observedToken = normalized;
469
806
  };
470
807
  context.on("request", (request) => {
471
808
  recordToken(request.headers()["auth-user-token"]);
@@ -473,8 +810,15 @@ async function loginWithBrowser(options, config) {
473
810
  try {
474
811
  const page = await context.newPage();
475
812
  const webOrigin = new URL(webBaseUrl).origin;
476
- await page.goto(`${webOrigin}/dashboard`, { waitUntil: "domcontentloaded" });
477
- await waitForEnter("Finish logging in, then press Enter to continue...");
813
+ const startPath = storedAuth?.loginPath ? normalizePath(storedAuth.loginPath) : "/dashboard";
814
+ await page.goto(new URL(startPath, webOrigin).toString(), { waitUntil: "domcontentloaded" });
815
+ debugLog(debug, `Opened ${startPath}`);
816
+ if (storedAuth) {
817
+ debugLog(debug, "auth.json detected; attempting auto-login.");
818
+ if (await attemptAutoLogin(page, storedAuth, debug)) {
819
+ if (!await waitForPasswordFieldGone(page, 15e3)) await waitForEnter("Finish logging in, then press Enter to continue...");
820
+ }
821
+ } else await waitForEnter("Finish logging in, then press Enter to continue...");
478
822
  if (!observedToken) {
479
823
  await page.reload({ waitUntil: "domcontentloaded" });
480
824
  await page.waitForTimeout(1500);
@@ -495,7 +839,7 @@ async function loginWithBrowser(options, config) {
495
839
  const csrfToken = csrfCookie ?? resolveCsrfToken(options, config);
496
840
  let raw;
497
841
  if (!observedToken) {
498
- if (!csrfToken) throw new Error("Missing CSRF token after login. Try again or provide --csrf.");
842
+ if (!csrfToken) throw new Error("Missing CSRF token after login. Try again or run 'kahunas workout sync'.");
499
843
  if (!cookieHeader) throw new Error("Missing cookies after login. Try again.");
500
844
  const { token: extractedToken, raw: fetchedRaw } = await fetchAuthToken(csrfToken, cookieHeader, webBaseUrl);
501
845
  recordToken(extractedToken);
@@ -516,10 +860,14 @@ async function loginWithBrowser(options, config) {
516
860
  }
517
861
  async function loginAndPersist(options, config, outputMode) {
518
862
  const result = await loginWithBrowser(options, config);
863
+ const tokenUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
864
+ const tokenExpiresAt = resolveTokenExpiry(result.token, tokenUpdatedAt) ?? null;
519
865
  const nextConfig = {
520
866
  ...config,
521
867
  token: result.token,
522
- webBaseUrl: result.webBaseUrl
868
+ webBaseUrl: result.webBaseUrl,
869
+ tokenUpdatedAt,
870
+ tokenExpiresAt
523
871
  };
524
872
  if (result.csrfToken) nextConfig.csrfToken = result.csrfToken;
525
873
  if (result.cookieHeader) nextConfig.authCookie = result.cookieHeader;
@@ -533,120 +881,7 @@ async function loginAndPersist(options, config, outputMode) {
533
881
  //#endregion
534
882
  //#region src/usage.ts
535
883
  function printUsage() {
536
- console.log(`kahunas - CLI for Kahunas API\n\nUsage:\n kahunas auth set <token> [--base-url URL] [--csrf CSRF] [--web-base-url URL] [--cookie COOKIE] [--csrf-cookie VALUE]\n kahunas auth token [--csrf CSRF] [--cookie COOKIE] [--csrf-cookie VALUE] [--web-base-url URL] [--raw]\n kahunas auth login [--web-base-url URL] [--headless] [--raw]\n kahunas auth status [--token TOKEN] [--base-url URL] [--auto-login] [--headless]\n kahunas auth show\n kahunas checkins list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout pick [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout latest [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout events [--user UUID] [--timezone TZ] [--program UUID] [--workout UUID] [--minimal] [--full] [--latest] [--limit N] [--debug-preview] [--raw] [--no-auto-login] [--headless]\n kahunas workout serve [--host HOST] [--port N] [--timezone TZ] [--program UUID] [--workout UUID] [--limit N] [--cache-ttl MS] [--no-auto-login] [--headless]\n kahunas workout sync [--headless]\n kahunas workout program <id> [--csrf CSRF] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n\nEnv:\n KAHUNAS_TOKEN=...\n KAHUNAS_CSRF=...\n KAHUNAS_CSRF_COOKIE=...\n KAHUNAS_COOKIE=...\n KAHUNAS_WEB_BASE_URL=...\n KAHUNAS_USER_UUID=...\n\nConfig:\n ${CONFIG_PATH}`);
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);
884
+ logPlain(`${formatHeading("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\n${formatDim("Config:")}\n ${CONFIG_PATH}`);
650
885
  }
651
886
 
652
887
  //#endregion
@@ -659,19 +894,17 @@ async function handleCheckins(positionals, options) {
659
894
  }
660
895
  if (action !== "list") throw new Error(`Unknown checkins action: ${action}`);
661
896
  const config = readConfig();
662
- const autoLogin = shouldAutoLogin(options, true);
663
897
  let token = resolveToken(options, config);
664
- if (!token) if (autoLogin) token = await loginAndPersist(options, config, "silent");
665
- else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
898
+ if (!token) token = await loginAndPersist(options, config, "silent");
666
899
  const baseUrl = resolveBaseUrl(options, config);
667
- const page = parseNumber$1(options.page, 1);
668
- const rpp = parseNumber$1(options.rpp, 12);
900
+ const page = 1;
901
+ const rpp = 12;
669
902
  const rawOutput = isFlagEnabled(options, "raw");
670
903
  let response = await postJson("/api/v2/checkin/list", token, baseUrl, {
671
904
  page,
672
905
  rpp
673
906
  });
674
- if (autoLogin && isTokenExpiredResponse(response.json)) {
907
+ if (isTokenExpiredResponse(response.json)) {
675
908
  token = await loginAndPersist(options, config, "silent");
676
909
  response = await postJson("/api/v2/checkin/list", token, baseUrl, {
677
910
  page,
@@ -1822,16 +2055,15 @@ async function handleWorkout(positionals, options) {
1822
2055
  return;
1823
2056
  }
1824
2057
  const config = readConfig();
1825
- const autoLogin = shouldAutoLogin(options, true);
2058
+ const debug = config.debug === true;
2059
+ const autoLogin = true;
1826
2060
  let token = resolveToken(options, config);
1827
2061
  const ensureToken = async () => {
1828
- if (!token) if (autoLogin) token = await loginAndPersist(options, config, "silent");
1829
- else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
2062
+ if (!token) token = await loginAndPersist(options, config, "silent");
1830
2063
  return token;
1831
2064
  };
1832
2065
  let webLoginInFlight = null;
1833
2066
  const ensureWebLogin = async () => {
1834
- if (!autoLogin) return;
1835
2067
  if (!webLoginInFlight) webLoginInFlight = loginAndPersist(options, config, "silent").then(() => void 0).finally(() => {
1836
2068
  webLoginInFlight = null;
1837
2069
  });
@@ -1839,16 +2071,15 @@ async function handleWorkout(positionals, options) {
1839
2071
  };
1840
2072
  const baseUrl = resolveBaseUrl(options, config);
1841
2073
  const rawOutput = isFlagEnabled(options, "raw");
1842
- const page = parseNumber$1(options.page, 1);
1843
- const rpp = parseNumber$1(options.rpp, 12);
1844
- const listRpp = action === "latest" && options.rpp === void 0 ? 100 : rpp;
2074
+ const page = 1;
2075
+ const listRpp = action === "latest" ? 100 : 12;
1845
2076
  const fetchList = async () => {
1846
2077
  await ensureToken();
1847
2078
  const url = new URL("/api/v1/workoutprogram", baseUrl);
1848
- if (page) url.searchParams.set("page", String(page));
2079
+ url.searchParams.set("page", String(page));
1849
2080
  if (listRpp) url.searchParams.set("rpp", String(listRpp));
1850
2081
  let response = await getWithAuth(url.pathname + url.search, token, baseUrl);
1851
- if (autoLogin && isTokenExpiredResponse(response.json)) {
2082
+ if (isTokenExpiredResponse(response.json)) {
1852
2083
  token = await loginAndPersist(options, config, "silent");
1853
2084
  response = await getWithAuth(url.pathname + url.search, token, baseUrl);
1854
2085
  }
@@ -1864,7 +2095,7 @@ async function handleWorkout(positionals, options) {
1864
2095
  const fetchWorkoutEventsPayload = async () => {
1865
2096
  const baseWebUrl = resolveWebBaseUrl(options, config);
1866
2097
  const webOrigin = new URL(baseWebUrl).origin;
1867
- const timezone = options.timezone ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "Europe/London";
2098
+ const timezone = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "Europe/London";
1868
2099
  let userUuid = resolveUserUuid(options, config);
1869
2100
  if (!userUuid) try {
1870
2101
  await ensureToken();
@@ -1872,7 +2103,7 @@ async function handleWorkout(positionals, options) {
1872
2103
  page: 1,
1873
2104
  rpp: 1
1874
2105
  });
1875
- if (autoLogin && isTokenExpiredResponse(checkinsResponse.json)) {
2106
+ if (isTokenExpiredResponse(checkinsResponse.json)) {
1876
2107
  token = await loginAndPersist(options, config, "silent");
1877
2108
  checkinsResponse = await postJson("/api/v2/checkin/list", token, baseUrl, {
1878
2109
  page: 1,
@@ -1890,7 +2121,7 @@ async function handleWorkout(positionals, options) {
1890
2121
  }
1891
2122
  }
1892
2123
  } catch {}
1893
- if (!userUuid) throw new Error("Missing user uuid. Use --user or run 'kahunas checkins list' once.");
2124
+ if (!userUuid) throw new Error("Missing user uuid. Run 'kahunas checkins list' or 'kahunas workout sync' once.");
1894
2125
  if (userUuid !== config.userUuid) writeConfig({
1895
2126
  ...config,
1896
2127
  userUuid
@@ -1909,13 +2140,13 @@ async function handleWorkout(positionals, options) {
1909
2140
  effectiveCsrfToken = csrfCookie ?? csrfToken$1;
1910
2141
  cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
1911
2142
  }
1912
- if (!effectiveCsrfToken) throw new Error("Missing CSRF token. Run 'kahunas auth login' and try again.");
1913
- if (!cookieHeader) throw new Error("Missing cookies. Run 'kahunas auth login' and try again.");
2143
+ if (!effectiveCsrfToken) throw new Error("Missing CSRF token. Run 'kahunas workout sync' and try again.");
2144
+ if (!cookieHeader) throw new Error("Missing cookies. Run 'kahunas workout sync' and try again.");
1914
2145
  const url = new URL(`/coach/clients/calendar/getEvent/${userUuid}`, webOrigin);
1915
2146
  url.searchParams.set("timezone", timezone);
1916
2147
  const body = new URLSearchParams();
1917
2148
  body.set("csrf_kahunas_token", effectiveCsrfToken);
1918
- body.set("filter", options.filter ?? "");
2149
+ body.set("filter", "");
1919
2150
  let response = await fetch(url.toString(), {
1920
2151
  method: "POST",
1921
2152
  headers: {
@@ -1930,7 +2161,7 @@ async function handleWorkout(positionals, options) {
1930
2161
  });
1931
2162
  let text = await response.text();
1932
2163
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`);
1933
- if (autoLogin && isLikelyLoginHtml(text)) {
2164
+ if (isLikelyLoginHtml(text)) {
1934
2165
  await ensureWebLogin();
1935
2166
  const refreshed = readConfig();
1936
2167
  csrfToken$1 = resolveCsrfToken(options, refreshed);
@@ -1938,7 +2169,7 @@ async function handleWorkout(positionals, options) {
1938
2169
  authCookie = resolveAuthCookie(options, refreshed);
1939
2170
  effectiveCsrfToken = csrfCookie ?? csrfToken$1;
1940
2171
  cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
1941
- if (!effectiveCsrfToken || !cookieHeader) throw new Error("Login required. Run 'kahunas auth login' and try again.");
2172
+ if (!effectiveCsrfToken || !cookieHeader) throw new Error("Login required. Run 'kahunas workout sync' and try again.");
1942
2173
  const retry = await fetch(url.toString(), {
1943
2174
  method: "POST",
1944
2175
  headers: {
@@ -1970,7 +2201,7 @@ async function handleWorkout(positionals, options) {
1970
2201
  listUrl.searchParams.set("page", "1");
1971
2202
  listUrl.searchParams.set("rpp", "100");
1972
2203
  let listResponse = await getWithAuth(listUrl.pathname + listUrl.search, token, baseUrl);
1973
- if (autoLogin && isTokenExpiredResponse(listResponse.json)) {
2204
+ if (isTokenExpiredResponse(listResponse.json)) {
1974
2205
  token = await loginAndPersist(options, config, "silent");
1975
2206
  listResponse = await getWithAuth(listUrl.pathname + listUrl.search, token, baseUrl);
1976
2207
  }
@@ -1990,7 +2221,7 @@ async function handleWorkout(positionals, options) {
1990
2221
  try {
1991
2222
  await ensureToken();
1992
2223
  let responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, programId$1, effectiveCsrfToken);
1993
- if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
2224
+ if (isTokenExpiredResponse(responseProgram$1.json)) {
1994
2225
  token = await loginAndPersist(options, config, "silent");
1995
2226
  responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, programId$1, effectiveCsrfToken);
1996
2227
  }
@@ -2056,9 +2287,9 @@ async function handleWorkout(positionals, options) {
2056
2287
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
2057
2288
  if (plans.length === 0) throw new Error("No workout programs found.");
2058
2289
  if (!rawOutput) {
2059
- console.log("Pick a workout program:");
2290
+ logPlain(formatHeading("Pick a workout program:"));
2060
2291
  plans.forEach((plan, index) => {
2061
- console.log(`${index + 1}) ${formatWorkoutSummary(plan)}`);
2292
+ logPlain(`${index + 1}) ${formatWorkoutSummary(plan)}`);
2062
2293
  });
2063
2294
  }
2064
2295
  const answer = await askQuestion(`Enter number (1-${plans.length}): `);
@@ -2068,7 +2299,7 @@ async function handleWorkout(positionals, options) {
2068
2299
  if (!chosen.uuid) throw new Error("Selected workout is missing a uuid.");
2069
2300
  const csrfToken$1 = resolveCsrfToken(options, config);
2070
2301
  let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
2071
- if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
2302
+ if (isTokenExpiredResponse(responseProgram$1.json)) {
2072
2303
  token = await loginAndPersist(options, config, "silent");
2073
2304
  responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
2074
2305
  }
@@ -2084,7 +2315,7 @@ async function handleWorkout(positionals, options) {
2084
2315
  if (!chosen || !chosen.uuid) throw new Error("Latest workout is missing a uuid.");
2085
2316
  const csrfToken$1 = resolveCsrfToken(options, config);
2086
2317
  let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
2087
- if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
2318
+ if (isTokenExpiredResponse(responseProgram$1.json)) {
2088
2319
  token = await loginAndPersist(options, config, "silent");
2089
2320
  responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
2090
2321
  }
@@ -2095,8 +2326,8 @@ async function handleWorkout(positionals, options) {
2095
2326
  if (action === "events") {
2096
2327
  const minimal = isFlagEnabled(options, "minimal");
2097
2328
  const full = isFlagEnabled(options, "full");
2098
- const debugPreview = isFlagEnabled(options, "debug-preview");
2099
- const limit = isFlagEnabled(options, "latest") || isFlagEnabled(options, "last") ? 1 : parseNumber$1(options.limit, 0);
2329
+ const debugPreview = isFlagEnabled(options, "debug-preview") || debug;
2330
+ const limit = 1;
2100
2331
  const { text, payload, timezone } = await fetchWorkoutEventsPayload();
2101
2332
  if (rawOutput) {
2102
2333
  console.log(text);
@@ -2106,7 +2337,7 @@ async function handleWorkout(positionals, options) {
2106
2337
  console.log(text);
2107
2338
  return;
2108
2339
  }
2109
- const sorted = sortWorkoutEvents(filterWorkoutEvents(payload, options.program, options.workout));
2340
+ const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
2110
2341
  const limited = limit > 0 ? sorted.slice(-limit) : sorted;
2111
2342
  if (minimal) {
2112
2343
  console.log(JSON.stringify(limited, null, 2));
@@ -2121,7 +2352,7 @@ async function handleWorkout(positionals, options) {
2121
2352
  const match = findWorkoutPreviewHtmlMatch(record) ?? (program ? findWorkoutPreviewHtmlMatch(program) : void 0);
2122
2353
  const dayIndex = resolveWorkoutEventDayIndex(entry, program);
2123
2354
  const source = match ? match.source : "not_found";
2124
- console.error(`debug-preview event=${eventId} program=${programUuid ?? "unknown"} day_index=${dayIndex ?? "none"} source=${source}`);
2355
+ debugLog(true, `preview event=${eventId} program=${programUuid ?? "unknown"} day_index=${dayIndex ?? "none"} source=${source}`);
2125
2356
  }
2126
2357
  if (full) {
2127
2358
  const enriched = enrichWorkoutEvents(limited, programDetails);
@@ -2130,27 +2361,27 @@ async function handleWorkout(positionals, options) {
2130
2361
  }
2131
2362
  const formatted = formatWorkoutEventsOutput(limited, programDetails, {
2132
2363
  timezone,
2133
- program: options.program,
2134
- workout: options.workout
2364
+ program: void 0,
2365
+ workout: void 0
2135
2366
  });
2136
2367
  console.log(JSON.stringify(formatted, null, 2));
2137
2368
  return;
2138
2369
  }
2139
2370
  if (action === "serve") {
2140
- const host = options.host ?? "127.0.0.1";
2141
- const port = parseNumber$1(options.port, 3e3);
2142
- const limit = parseNumber$1(options.limit, 1);
2143
- const cacheTtlMs = parseNumber$1(options["cache-ttl"], 3e4);
2371
+ const host = "127.0.0.1";
2372
+ const port = 3e3;
2373
+ const limit = 1;
2374
+ const cacheTtlMs = 3e4;
2144
2375
  const loadSummary = async () => {
2145
2376
  const { text, payload, timezone } = await fetchWorkoutEventsPayload();
2146
2377
  if (!Array.isArray(payload)) throw new Error(`Unexpected calendar response: ${text.slice(0, 200)}`);
2147
- const sorted = sortWorkoutEvents(filterWorkoutEvents(payload, options.program, options.workout));
2378
+ const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
2148
2379
  const bounded = limit > 0 ? sorted.slice(-limit) : sorted;
2149
2380
  const programDetails = await buildProgramDetails(sorted);
2150
2381
  const formatted = formatWorkoutEventsOutput(bounded, programDetails, {
2151
2382
  timezone,
2152
- program: options.program,
2153
- workout: options.workout
2383
+ program: void 0,
2384
+ workout: void 0
2154
2385
  });
2155
2386
  const summary = formatted.events[0];
2156
2387
  const programUuid = summary?.program?.uuid ?? (bounded[0] && typeof bounded[0] === "object" ? bounded[0].program : void 0);
@@ -2221,15 +2452,47 @@ async function handleWorkout(positionals, options) {
2221
2452
  res.end(error instanceof Error ? error.message : "Server error");
2222
2453
  }
2223
2454
  }).listen(port, host, () => {
2224
- console.log(`Local workout server running at http://${host}:${port}`);
2225
- console.log(`JSON endpoint at http://${host}:${port}/api/workout`);
2455
+ const cache = readWorkoutCache();
2456
+ const freshConfig = readConfig();
2457
+ const lastSync = cache?.updatedAt ?? "none";
2458
+ const tokenExpiry = freshConfig.tokenExpiresAt ?? "unknown";
2459
+ const tokenUpdatedAt = freshConfig.tokenUpdatedAt ?? "unknown";
2460
+ logInfo(`Local workout server running at http://${host}:${port}`);
2461
+ logInfo(`JSON endpoint at http://${host}:${port}/api/workout`);
2462
+ logInfo(`Config: ${CONFIG_PATH}`);
2463
+ logInfo(`Last workout sync: ${lastSync}`);
2464
+ logInfo(`Token expiry: ${tokenExpiry}`);
2465
+ if (tokenExpiry === "unknown" && tokenUpdatedAt !== "unknown") logInfo(`Token updated at: ${tokenUpdatedAt}`);
2226
2466
  });
2227
2467
  return;
2228
2468
  }
2229
2469
  if (action === "sync") {
2470
+ const authConfig = readAuthConfig();
2471
+ const hasAuthConfig = !!authConfig && !!authConfig.password && (!!authConfig.email || !!authConfig.username);
2472
+ const tokenUpdatedAt = config.tokenUpdatedAt ?? void 0;
2473
+ const tokenExpiry = token && tokenUpdatedAt ? resolveTokenExpiry(token, tokenUpdatedAt) : null;
2474
+ if (!(!!tokenExpiry && Date.now() < Date.parse(tokenExpiry)) && !hasAuthConfig) {
2475
+ if (!process.stdin.isTTY) throw new Error("Missing auth credentials. Create ~/.config/kahunas/auth.json or run 'kahunas sync' in a terminal.");
2476
+ const login = await askQuestion("Email or username: ", process.stderr);
2477
+ if (!login) throw new Error("Missing email/username for login.");
2478
+ const password = await askQuestion("Password: ", process.stderr);
2479
+ if (!password) throw new Error("Missing password for login.");
2480
+ const isEmail = login.includes("@");
2481
+ writeAuthConfig({
2482
+ email: isEmail ? login : void 0,
2483
+ username: isEmail ? void 0 : login,
2484
+ password
2485
+ });
2486
+ console.error(`Saved credentials to ${AUTH_PATH}`);
2487
+ }
2230
2488
  const captured = await captureWorkoutsFromBrowser(options, config);
2231
2489
  const nextConfig = { ...config };
2232
- if (captured.token) nextConfig.token = captured.token;
2490
+ if (captured.token) {
2491
+ nextConfig.token = captured.token;
2492
+ const tokenUpdatedAt$1 = (/* @__PURE__ */ new Date()).toISOString();
2493
+ nextConfig.tokenUpdatedAt = tokenUpdatedAt$1;
2494
+ nextConfig.tokenExpiresAt = resolveTokenExpiry(captured.token, tokenUpdatedAt$1) ?? null;
2495
+ }
2233
2496
  if (captured.csrfToken) nextConfig.csrfToken = captured.csrfToken;
2234
2497
  if (captured.webBaseUrl) nextConfig.webBaseUrl = captured.webBaseUrl;
2235
2498
  if (captured.cookieHeader) nextConfig.authCookie = captured.cookieHeader;
@@ -2244,6 +2507,9 @@ async function handleWorkout(positionals, options) {
2244
2507
  path: WORKOUT_CACHE_PATH
2245
2508
  }
2246
2509
  }, null, 2));
2510
+ if (process.stdin.isTTY) {
2511
+ if ((await askQuestion("Start the preview server now? (y/N): ", process.stderr)).toLowerCase().startsWith("y")) await handleWorkout(["serve"], options);
2512
+ }
2247
2513
  return;
2248
2514
  }
2249
2515
  if (action !== "program") throw new Error(`Unknown workout action: ${action}`);
@@ -2252,7 +2518,7 @@ async function handleWorkout(positionals, options) {
2252
2518
  const ensuredToken = await ensureToken();
2253
2519
  const csrfToken = resolveCsrfToken(options, config);
2254
2520
  let responseProgram = await fetchWorkoutProgram(ensuredToken, baseUrl, programId, csrfToken);
2255
- if (autoLogin && isTokenExpiredResponse(responseProgram.json)) {
2521
+ if (isTokenExpiredResponse(responseProgram.json)) {
2256
2522
  token = await loginAndPersist(options, config, "silent");
2257
2523
  responseProgram = await fetchWorkoutProgram(token, baseUrl, programId, csrfToken);
2258
2524
  }
@@ -2271,12 +2537,15 @@ async function main() {
2271
2537
  const command = positionals[0];
2272
2538
  const rest = positionals.slice(1);
2273
2539
  switch (command) {
2274
- case "auth":
2275
- await handleAuth(rest, options);
2276
- return;
2277
2540
  case "checkins":
2278
2541
  await handleCheckins(rest, options);
2279
2542
  return;
2543
+ case "sync":
2544
+ await handleWorkout(["sync", ...rest], options);
2545
+ return;
2546
+ case "serve":
2547
+ await handleWorkout(["serve", ...rest], options);
2548
+ return;
2280
2549
  case "workout":
2281
2550
  await handleWorkout(rest, options);
2282
2551
  return;
@@ -2287,8 +2556,7 @@ async function main() {
2287
2556
  }
2288
2557
  }
2289
2558
  main().catch((error) => {
2290
- const message = error instanceof Error ? error.message : String(error);
2291
- console.error(message);
2559
+ logError(error instanceof Error ? error.message : String(error));
2292
2560
  process.exit(1);
2293
2561
  });
2294
2562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kahunas-cli",
3
- "version": "1.0.6",
3
+ "version": "1.3.0",
4
4
  "description": "",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -19,6 +19,7 @@
19
19
  "ai-screenshot.png"
20
20
  ],
21
21
  "dependencies": {
22
+ "picocolors": "^1.1.1",
22
23
  "playwright": "^1.49.1"
23
24
  },
24
25
  "devDependencies": {