kahunas-cli 1.0.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 +165 -0
  2. package/dist/cli.js +1018 -0
  3. package/package.json +36 -0
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # Kahunas CLI
2
+
3
+ A TypeScript CLI for Kahunas (kahunas.io) to fetch check-ins and workouts.
4
+
5
+ ![Kahunas CLI screenshot](ai-screenshot.png)
6
+
7
+ ## Quick start
8
+
9
+ 1) Install dependencies and build:
10
+
11
+ ```bash
12
+ pnpm install
13
+ pnpm build
14
+ ```
15
+
16
+ 2) Log in once (opens a browser):
17
+
18
+ ```bash
19
+ node dist/cli.js auth login
20
+ ```
21
+
22
+ 3) Fetch data:
23
+
24
+ ```bash
25
+ node dist/cli.js checkins list
26
+ node dist/cli.js workout list
27
+ node dist/cli.js workout pick
28
+ ```
29
+
30
+ You can also use the pnpm shortcut:
31
+
32
+ ```bash
33
+ pnpm kahunas -- checkins list
34
+ pnpm kahunas -- workout events
35
+ ```
36
+
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 (from the calendar endpoint).
67
+ - `kahunas workout program <id>`
68
+ - Fetches a program by UUID.
69
+
70
+ ### Workout sync (browser capture)
71
+
72
+ If the API list is missing a program you see in the web UI, run:
73
+
74
+ ```bash
75
+ node dist/cli.js workout sync
76
+ ```
77
+
78
+ 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:
79
+
80
+ - `~/.config/kahunas/workouts.json`
81
+
82
+ `workout list`, `workout pick`, and `workout latest` automatically merge the API list with this cache.
83
+ Raw output (`--raw`) prints the API response only.
84
+
85
+ ### Workout events (dates)
86
+
87
+ To see when workouts happened, the calendar endpoint returns log events with timestamps. By default each event is enriched with the full program payload (best effort; falls back to cached summary if needed).
88
+
89
+ ```bash
90
+ node dist/cli.js workout events --user <user-uuid>
91
+ ```
92
+
93
+ Or via pnpm:
94
+
95
+ ```bash
96
+ pnpm kahunas -- workout events
97
+ ```
98
+
99
+ Default timezone is `Europe/London`. Override with `--timezone`.
100
+
101
+ You can filter by program or workout UUID:
102
+
103
+ ```bash
104
+ node dist/cli.js workout events --program <program-uuid>
105
+ node dist/cli.js workout events --workout <workout-uuid>
106
+ ```
107
+
108
+ Use `--minimal` to return the raw event objects without program enrichment.
109
+
110
+ The user UUID is saved automatically after `checkins list`, or you can set it:
111
+
112
+ - `KAHUNAS_USER_UUID=...`
113
+ - `--user <uuid>`
114
+
115
+ ## Auto-login
116
+
117
+ Most commands auto-login by default if a token is missing or expired. To disable:
118
+
119
+ ```bash
120
+ node dist/cli.js checkins list --no-auto-login
121
+ ```
122
+
123
+ ## Flags
124
+
125
+ - `--raw` prints raw API responses (no formatting).
126
+ - `--headless` runs Playwright without a visible browser window.
127
+
128
+ ## Environment variables
129
+
130
+ - `KAHUNAS_TOKEN`
131
+ - `KAHUNAS_CSRF`
132
+ - `KAHUNAS_CSRF_COOKIE`
133
+ - `KAHUNAS_COOKIE`
134
+ - `KAHUNAS_WEB_BASE_URL`
135
+ - `KAHUNAS_USER_UUID`
136
+
137
+ ## Playwright
138
+
139
+ If Playwright did not download a browser during install:
140
+
141
+ ```bash
142
+ pnpm exec playwright install chromium
143
+ ```
144
+
145
+ ## Testing
146
+
147
+ Run the unit tests with:
148
+
149
+ ```bash
150
+ pnpm test
151
+ ```
152
+
153
+ ## Publishing
154
+
155
+ The npm package is built from `dist/cli.js`. Publishing runs the build automatically:
156
+
157
+ ```bash
158
+ pnpm publish
159
+ ```
160
+
161
+ ## Notes
162
+
163
+ - This CLI uses the same APIs the web app uses; tokens can expire quickly.
164
+ - `auth login` is the most reliable way to refresh the token.
165
+ - `workout events` relies on session cookies captured during `auth login`.
package/dist/cli.js ADDED
@@ -0,0 +1,1018 @@
1
+ #!/usr/bin/env node
2
+ //#region rolldown:runtime
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
+ key = keys[i];
13
+ if (!__hasOwnProp.call(to, key) && key !== except) {
14
+ __defProp(to, key, {
15
+ get: ((k) => from[k]).bind(null, key),
16
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
17
+ });
18
+ }
19
+ }
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
24
+ value: mod,
25
+ enumerable: true
26
+ }) : target, mod));
27
+
28
+ //#endregion
29
+ let node_fs = require("node:fs");
30
+ node_fs = __toESM(node_fs);
31
+ let node_os = require("node:os");
32
+ node_os = __toESM(node_os);
33
+ let node_path = require("node:path");
34
+ node_path = __toESM(node_path);
35
+ let node_readline = require("node:readline");
36
+ node_readline = __toESM(node_readline);
37
+
38
+ //#region src/args.ts
39
+ function parseArgs(argv) {
40
+ const positionals = [];
41
+ const options = {};
42
+ for (let index = 0; index < argv.length; index += 1) {
43
+ const arg = argv[index];
44
+ if (!arg.startsWith("--")) {
45
+ positionals.push(arg);
46
+ continue;
47
+ }
48
+ const [key, inlineValue] = arg.slice(2).split("=");
49
+ if (inlineValue !== void 0) {
50
+ options[key] = inlineValue;
51
+ continue;
52
+ }
53
+ const next = argv[index + 1];
54
+ if (next && !next.startsWith("--")) {
55
+ options[key] = next;
56
+ index += 1;
57
+ continue;
58
+ }
59
+ options[key] = "true";
60
+ }
61
+ return {
62
+ positionals,
63
+ options
64
+ };
65
+ }
66
+ function isFlagEnabled(options, name) {
67
+ const value = options[name];
68
+ return value === "true" || value === "1" || value === "yes";
69
+ }
70
+ function shouldAutoLogin(options, defaultValue) {
71
+ if (isFlagEnabled(options, "auto-login")) return true;
72
+ if (isFlagEnabled(options, "no-auto-login")) return false;
73
+ return defaultValue;
74
+ }
75
+
76
+ //#endregion
77
+ //#region src/config.ts
78
+ const DEFAULT_BASE_URL = "https://api.kahunas.io";
79
+ const DEFAULT_WEB_BASE_URL = "https://kahunas.io";
80
+ const CONFIG_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "config.json");
81
+ const WORKOUT_CACHE_PATH = node_path.join(node_os.homedir(), ".config", "kahunas", "workouts.json");
82
+ function readConfig() {
83
+ if (!node_fs.existsSync(CONFIG_PATH)) return {};
84
+ const raw = node_fs.readFileSync(CONFIG_PATH, "utf-8");
85
+ try {
86
+ return JSON.parse(raw);
87
+ } catch {
88
+ throw new Error(`Invalid JSON in ${CONFIG_PATH}.`);
89
+ }
90
+ }
91
+ function writeConfig(config) {
92
+ const dir = node_path.dirname(CONFIG_PATH);
93
+ node_fs.mkdirSync(dir, { recursive: true });
94
+ node_fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
95
+ }
96
+ function readWorkoutCache() {
97
+ if (!node_fs.existsSync(WORKOUT_CACHE_PATH)) return;
98
+ const raw = node_fs.readFileSync(WORKOUT_CACHE_PATH, "utf-8");
99
+ try {
100
+ return JSON.parse(raw);
101
+ } catch {
102
+ return;
103
+ }
104
+ }
105
+ function writeWorkoutCache(plans) {
106
+ const dir = node_path.dirname(WORKOUT_CACHE_PATH);
107
+ node_fs.mkdirSync(dir, { recursive: true });
108
+ const cache = {
109
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
110
+ plans
111
+ };
112
+ node_fs.writeFileSync(WORKOUT_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, "utf-8");
113
+ return cache;
114
+ }
115
+ function resolveToken(options, config) {
116
+ return options.token ?? process.env.KAHUNAS_TOKEN ?? config.token;
117
+ }
118
+ function resolveCsrfToken(options, config) {
119
+ return options.csrf ?? process.env.KAHUNAS_CSRF ?? config.csrfToken;
120
+ }
121
+ function resolveCsrfCookie(options, config) {
122
+ return options["csrf-cookie"] ?? process.env.KAHUNAS_CSRF_COOKIE ?? config.csrfCookie;
123
+ }
124
+ function resolveAuthCookie(options, config) {
125
+ return options.cookie ?? process.env.KAHUNAS_COOKIE ?? config.authCookie;
126
+ }
127
+ function resolveUserUuid(options, config) {
128
+ return options.user ?? process.env.KAHUNAS_USER_UUID ?? config.userUuid;
129
+ }
130
+ function resolveBaseUrl(options, config) {
131
+ return options["base-url"] ?? config.baseUrl ?? DEFAULT_BASE_URL;
132
+ }
133
+ function resolveWebBaseUrl(options, config) {
134
+ return options["web-base-url"] ?? process.env.KAHUNAS_WEB_BASE_URL ?? config.webBaseUrl ?? DEFAULT_WEB_BASE_URL;
135
+ }
136
+
137
+ //#endregion
138
+ //#region src/tokens.ts
139
+ function isLikelyAuthToken(value) {
140
+ if (value.length >= 80) return true;
141
+ if (value.includes(".") && value.split(".").length >= 3) return true;
142
+ return /[+/=]/.test(value) && value.length >= 40;
143
+ }
144
+ function findTokenInUnknown(value) {
145
+ if (typeof value === "string") return isLikelyAuthToken(value) ? value : void 0;
146
+ if (Array.isArray(value)) {
147
+ for (const entry of value) {
148
+ const token = findTokenInUnknown(entry);
149
+ if (token) return token;
150
+ }
151
+ return;
152
+ }
153
+ if (value && typeof value === "object") for (const [key, entry] of Object.entries(value)) {
154
+ if (typeof entry === "string" && key.toLowerCase().includes("token")) {
155
+ if (isLikelyAuthToken(entry)) return entry;
156
+ }
157
+ const token = findTokenInUnknown(entry);
158
+ if (token) return token;
159
+ }
160
+ }
161
+ function extractToken(text) {
162
+ try {
163
+ return findTokenInUnknown(JSON.parse(text));
164
+ } catch {
165
+ const trimmed = text.trim();
166
+ return trimmed ? trimmed : void 0;
167
+ }
168
+ }
169
+ function isLikelyLoginHtml(text) {
170
+ const trimmed = text.trim().toLowerCase();
171
+ if (!trimmed.startsWith("<")) return false;
172
+ return trimmed.includes("login to your account") || trimmed.includes("welcome back") || trimmed.includes("<title>kahunas");
173
+ }
174
+
175
+ //#endregion
176
+ //#region src/http.ts
177
+ function parseJsonText(text) {
178
+ try {
179
+ return JSON.parse(text);
180
+ } catch {
181
+ return;
182
+ }
183
+ }
184
+ async function postJson(pathName, token, baseUrl, body) {
185
+ const url = new URL(pathName, baseUrl).toString();
186
+ const response = await fetch(url, {
187
+ method: "POST",
188
+ headers: {
189
+ accept: "application/json",
190
+ "content-type": "application/json",
191
+ "auth-user-token": token,
192
+ origin: "https://kahunas.io",
193
+ referer: "https://kahunas.io/"
194
+ },
195
+ body: JSON.stringify(body)
196
+ });
197
+ const text = await response.text();
198
+ return {
199
+ ok: response.ok,
200
+ status: response.status,
201
+ text,
202
+ json: parseJsonText(text)
203
+ };
204
+ }
205
+ async function getWithAuth(pathName, token, baseUrl) {
206
+ const url = new URL(pathName, baseUrl).toString();
207
+ const response = await fetch(url, {
208
+ method: "GET",
209
+ headers: {
210
+ accept: "*/*",
211
+ "auth-user-token": token,
212
+ origin: "https://kahunas.io",
213
+ referer: "https://kahunas.io/"
214
+ }
215
+ });
216
+ const text = await response.text();
217
+ return {
218
+ ok: response.ok,
219
+ status: response.status,
220
+ text,
221
+ json: parseJsonText(text)
222
+ };
223
+ }
224
+ async function fetchWorkoutProgram(token, baseUrl, programId, csrfToken) {
225
+ const url = new URL(`/api/v1/workoutprogram/${programId}`, baseUrl);
226
+ if (csrfToken) url.searchParams.set("csrf_kahunas_token", csrfToken);
227
+ return getWithAuth(url.pathname + url.search, token, baseUrl);
228
+ }
229
+ async function fetchAuthToken(csrfToken, cookieHeader, webBaseUrl) {
230
+ const webOrigin = new URL(webBaseUrl).origin;
231
+ const url = new URL("/get-token", webOrigin);
232
+ url.searchParams.set("csrf_kahunas_token", csrfToken);
233
+ const response = await fetch(url.toString(), {
234
+ method: "GET",
235
+ headers: {
236
+ accept: "*/*",
237
+ cookie: cookieHeader,
238
+ origin: webOrigin,
239
+ referer: `${webOrigin}/dashboard`,
240
+ "x-requested-with": "XMLHttpRequest"
241
+ }
242
+ });
243
+ const text = await response.text();
244
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}: ${text}`);
245
+ return {
246
+ token: extractToken(text),
247
+ raw: text
248
+ };
249
+ }
250
+
251
+ //#endregion
252
+ //#region src/responses.ts
253
+ function isTokenExpiredResponse(payload) {
254
+ if (!payload || typeof payload !== "object") return false;
255
+ const record = payload;
256
+ if (record.token_expired === 1 || record.token_expired === true) return true;
257
+ if (record.status === -3) return true;
258
+ if (typeof record.message === "string" && record.message.toLowerCase().includes("login")) return true;
259
+ return false;
260
+ }
261
+ function extractUserUuidFromCheckins(payload) {
262
+ if (!payload || typeof payload !== "object") return;
263
+ const data = payload.data;
264
+ if (!data || typeof data !== "object") return;
265
+ const checkins = data.checkins;
266
+ if (!Array.isArray(checkins) || checkins.length === 0) return;
267
+ const first = checkins[0];
268
+ if (!first || typeof first !== "object") return;
269
+ const candidate = first.user_uuid;
270
+ return typeof candidate === "string" ? candidate : void 0;
271
+ }
272
+
273
+ //#endregion
274
+ //#region src/utils.ts
275
+ function parseNumber(value, fallback) {
276
+ if (!value) return fallback;
277
+ const parsed = Number.parseInt(value, 10);
278
+ if (Number.isNaN(parsed)) return fallback;
279
+ return parsed;
280
+ }
281
+ function askQuestion(prompt) {
282
+ return new Promise((resolve) => {
283
+ const rl = node_readline.createInterface({
284
+ input: process.stdin,
285
+ output: process.stdout
286
+ });
287
+ rl.question(prompt, (answer) => {
288
+ rl.close();
289
+ resolve(answer.trim());
290
+ });
291
+ });
292
+ }
293
+ function waitForEnter(prompt) {
294
+ return askQuestion(prompt).then(() => void 0);
295
+ }
296
+
297
+ //#endregion
298
+ //#region src/workouts.ts
299
+ function mapWorkoutPlan(entry) {
300
+ const uuid = typeof entry.uuid === "string" ? entry.uuid : void 0;
301
+ const title = typeof entry.title === "string" ? entry.title : typeof entry.name === "string" ? entry.name : void 0;
302
+ if (!uuid || !title) return;
303
+ return {
304
+ uuid,
305
+ title,
306
+ updated_at_utc: typeof entry.updated_at_utc === "number" ? entry.updated_at_utc : void 0,
307
+ created_at_utc: typeof entry.created_at_utc === "number" ? entry.created_at_utc : void 0,
308
+ days: typeof entry.days === "number" ? entry.days : void 0
309
+ };
310
+ }
311
+ function findWorkoutPlansDeep(payload) {
312
+ const results = [];
313
+ const seen = /* @__PURE__ */ new Set();
314
+ const record = (plan) => {
315
+ if (!plan || !plan.uuid) return;
316
+ if (seen.has(plan.uuid)) return;
317
+ seen.add(plan.uuid);
318
+ results.push(plan);
319
+ };
320
+ const visit = (value) => {
321
+ if (Array.isArray(value)) {
322
+ let foundCandidate = false;
323
+ for (const entry of value) if (entry && typeof entry === "object") {
324
+ const plan = mapWorkoutPlan(entry);
325
+ if (plan) {
326
+ record(plan);
327
+ foundCandidate = true;
328
+ }
329
+ }
330
+ if (foundCandidate) return;
331
+ for (const entry of value) visit(entry);
332
+ return;
333
+ }
334
+ if (value && typeof value === "object") {
335
+ const plan = mapWorkoutPlan(value);
336
+ if (plan) record(plan);
337
+ for (const entry of Object.values(value)) visit(entry);
338
+ }
339
+ };
340
+ visit(payload);
341
+ return results;
342
+ }
343
+ function mergeWorkoutPlans(primary, secondary) {
344
+ const merged = [];
345
+ const seen = /* @__PURE__ */ new Set();
346
+ const pushPlan = (plan) => {
347
+ if (!plan.uuid || seen.has(plan.uuid)) return;
348
+ seen.add(plan.uuid);
349
+ merged.push(plan);
350
+ };
351
+ for (const plan of primary) pushPlan(plan);
352
+ for (const plan of secondary) pushPlan(plan);
353
+ return merged;
354
+ }
355
+ function extractWorkoutPlans(payload) {
356
+ if (!payload || typeof payload !== "object") return [];
357
+ const data = payload.data;
358
+ if (!data || typeof data !== "object") return findWorkoutPlansDeep(payload);
359
+ const dataRecord = data;
360
+ const keys = [
361
+ "workout_plan",
362
+ "workout_plans",
363
+ "workout_program",
364
+ "workout_programs"
365
+ ];
366
+ const plans = [];
367
+ for (const key of keys) {
368
+ const workoutPlan = dataRecord[key];
369
+ if (Array.isArray(workoutPlan)) {
370
+ for (const entry of workoutPlan) if (entry && typeof entry === "object") {
371
+ const plan = mapWorkoutPlan(entry);
372
+ if (plan) plans.push(plan);
373
+ }
374
+ continue;
375
+ }
376
+ if (workoutPlan && typeof workoutPlan === "object") {
377
+ const plan = mapWorkoutPlan(workoutPlan);
378
+ if (plan) plans.push(plan);
379
+ }
380
+ }
381
+ if (plans.length > 0) return plans;
382
+ return findWorkoutPlansDeep(payload);
383
+ }
384
+ function pickLatestWorkout(plans) {
385
+ return [...plans].sort((a, b) => {
386
+ const aValue = a.updated_at_utc ?? a.created_at_utc ?? 0;
387
+ return (b.updated_at_utc ?? b.created_at_utc ?? 0) - aValue;
388
+ })[0];
389
+ }
390
+ function formatWorkoutSummary(plan) {
391
+ const title = plan.title ?? "Untitled";
392
+ const uuid = plan.uuid ?? "unknown";
393
+ return `${title}${plan.days ? ` - ${plan.days} days` : ""} (${uuid})`;
394
+ }
395
+ function buildWorkoutPlanIndex(plans) {
396
+ const index = {};
397
+ for (const plan of plans) if (plan.uuid) index[plan.uuid] = plan;
398
+ return index;
399
+ }
400
+
401
+ //#endregion
402
+ //#region src/auth.ts
403
+ async function captureWorkoutsFromBrowser(options, config) {
404
+ const webBaseUrl = resolveWebBaseUrl(options, config);
405
+ const headless = isFlagEnabled(options, "headless");
406
+ const browser = await (await import("playwright")).chromium.launch({ headless });
407
+ const context = await browser.newContext();
408
+ const plans = [];
409
+ const seen = /* @__PURE__ */ new Set();
410
+ let observedToken;
411
+ const recordToken = (candidate) => {
412
+ if (!candidate || observedToken) return;
413
+ if (isLikelyAuthToken(candidate)) observedToken = candidate;
414
+ };
415
+ const recordPlans = (incoming) => {
416
+ for (const plan of incoming) {
417
+ if (!plan.uuid || seen.has(plan.uuid)) continue;
418
+ seen.add(plan.uuid);
419
+ plans.push(plan);
420
+ }
421
+ };
422
+ context.on("request", (request) => {
423
+ recordToken(request.headers()["auth-user-token"]);
424
+ });
425
+ context.on("response", async (response) => {
426
+ const url = response.url();
427
+ if (!url.includes("api.kahunas.io") || !/workout|program/i.test(url)) return;
428
+ if (!(response.headers()["content-type"] ?? "").includes("application/json")) return;
429
+ try {
430
+ const extracted = extractWorkoutPlans(await response.json());
431
+ if (extracted.length > 0) recordPlans(extracted);
432
+ } catch {}
433
+ });
434
+ let csrfToken;
435
+ let cookieHeader;
436
+ let csrfCookie;
437
+ try {
438
+ const page = await context.newPage();
439
+ const webOrigin = new URL(webBaseUrl).origin;
440
+ await page.goto(`${webOrigin}/dashboard`, { waitUntil: "domcontentloaded" });
441
+ await waitForEnter("Log in, open your workouts page, then press Enter to capture...");
442
+ const cookies = await context.cookies(webOrigin);
443
+ cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
444
+ csrfCookie = cookies.find((cookie) => cookie.name === "csrf_kahunas_cookie_token")?.value;
445
+ csrfToken = csrfCookie ?? resolveCsrfToken(options, config);
446
+ if (plans.length === 0) await page.waitForTimeout(1500);
447
+ } finally {
448
+ await browser.close();
449
+ }
450
+ return {
451
+ plans,
452
+ token: observedToken,
453
+ csrfToken,
454
+ webBaseUrl,
455
+ cookieHeader,
456
+ csrfCookie
457
+ };
458
+ }
459
+ async function loginWithBrowser(options, config) {
460
+ const webBaseUrl = resolveWebBaseUrl(options, config);
461
+ const headless = isFlagEnabled(options, "headless");
462
+ const browser = await (await import("playwright")).chromium.launch({ headless });
463
+ const context = await browser.newContext();
464
+ let observedToken;
465
+ const recordToken = (candidate) => {
466
+ if (!candidate || observedToken) return;
467
+ if (isLikelyAuthToken(candidate)) observedToken = candidate;
468
+ };
469
+ context.on("request", (request) => {
470
+ recordToken(request.headers()["auth-user-token"]);
471
+ });
472
+ try {
473
+ const page = await context.newPage();
474
+ const webOrigin = new URL(webBaseUrl).origin;
475
+ await page.goto(`${webOrigin}/dashboard`, { waitUntil: "domcontentloaded" });
476
+ await waitForEnter("Finish logging in, then press Enter to continue...");
477
+ if (!observedToken) {
478
+ await page.reload({ waitUntil: "domcontentloaded" });
479
+ await page.waitForTimeout(1500);
480
+ }
481
+ if (!observedToken) {
482
+ const storageDump = await page.evaluate(() => {
483
+ return {
484
+ localEntries: Object.entries(localStorage),
485
+ sessionEntries: Object.entries(sessionStorage)
486
+ };
487
+ });
488
+ for (const [, value] of storageDump.localEntries) recordToken(extractToken(value));
489
+ for (const [, value] of storageDump.sessionEntries) recordToken(extractToken(value));
490
+ }
491
+ const cookies = await context.cookies(webOrigin);
492
+ const cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
493
+ const csrfCookie = cookies.find((cookie) => cookie.name === "csrf_kahunas_cookie_token")?.value;
494
+ const csrfToken = csrfCookie ?? resolveCsrfToken(options, config);
495
+ let raw;
496
+ if (!observedToken) {
497
+ if (!csrfToken) throw new Error("Missing CSRF token after login. Try again or provide --csrf.");
498
+ if (!cookieHeader) throw new Error("Missing cookies after login. Try again.");
499
+ const { token: extractedToken, raw: fetchedRaw } = await fetchAuthToken(csrfToken, cookieHeader, webBaseUrl);
500
+ recordToken(extractedToken);
501
+ raw = fetchedRaw;
502
+ }
503
+ if (!observedToken) throw new Error("Unable to extract auth token after login.");
504
+ return {
505
+ token: observedToken,
506
+ csrfToken,
507
+ webBaseUrl,
508
+ raw,
509
+ cookieHeader,
510
+ csrfCookie
511
+ };
512
+ } finally {
513
+ await browser.close();
514
+ }
515
+ }
516
+ async function loginAndPersist(options, config, outputMode) {
517
+ const result = await loginWithBrowser(options, config);
518
+ const nextConfig = {
519
+ ...config,
520
+ token: result.token,
521
+ webBaseUrl: result.webBaseUrl
522
+ };
523
+ if (result.csrfToken) nextConfig.csrfToken = result.csrfToken;
524
+ if (result.cookieHeader) nextConfig.authCookie = result.cookieHeader;
525
+ if (result.csrfCookie) nextConfig.csrfCookie = result.csrfCookie;
526
+ writeConfig(nextConfig);
527
+ if (outputMode !== "silent") if (outputMode === "raw") console.log(result.raw ?? result.token);
528
+ else console.log(result.token);
529
+ return result.token;
530
+ }
531
+
532
+ //#endregion
533
+ //#region src/usage.ts
534
+ function printUsage() {
535
+ 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] [--raw] [--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}`);
536
+ }
537
+
538
+ //#endregion
539
+ //#region src/commands/auth.ts
540
+ async function handleAuth(positionals, options) {
541
+ const action = positionals[0];
542
+ if (!action || action === "help") {
543
+ printUsage();
544
+ return;
545
+ }
546
+ if (action === "set") {
547
+ const token = positionals[1] ?? options.token;
548
+ if (!token) throw new Error("Missing token for auth set.");
549
+ const config = readConfig();
550
+ const baseUrl = resolveBaseUrl(options, config);
551
+ const csrfToken = resolveCsrfToken(options, config);
552
+ const webBaseUrl = resolveWebBaseUrl(options, config);
553
+ const authCookie = resolveAuthCookie(options, config);
554
+ const csrfCookie = resolveCsrfCookie(options, config);
555
+ writeConfig({
556
+ ...config,
557
+ token,
558
+ baseUrl,
559
+ csrfToken,
560
+ webBaseUrl,
561
+ authCookie,
562
+ csrfCookie
563
+ });
564
+ console.log(`Saved token to ${CONFIG_PATH}`);
565
+ return;
566
+ }
567
+ if (action === "token") {
568
+ const config = readConfig();
569
+ const csrfToken = resolveCsrfToken(options, config);
570
+ if (!csrfToken) throw new Error("Missing CSRF token. Provide --csrf or set KAHUNAS_CSRF.");
571
+ const webBaseUrl = resolveWebBaseUrl(options, config);
572
+ const authCookie = resolveAuthCookie(options, config);
573
+ const csrfCookie = resolveCsrfCookie(options, config);
574
+ const cookieHeader = authCookie ?? `csrf_kahunas_cookie_token=${csrfCookie ?? csrfToken}`;
575
+ const rawOutput = isFlagEnabled(options, "raw");
576
+ const { token: extractedToken, raw } = await fetchAuthToken(csrfToken, cookieHeader, webBaseUrl);
577
+ const token = extractedToken && isLikelyAuthToken(extractedToken) ? extractedToken : void 0;
578
+ if (rawOutput) {
579
+ console.log(raw);
580
+ return;
581
+ }
582
+ if (!token) {
583
+ console.log(raw);
584
+ return;
585
+ }
586
+ const nextConfig = {
587
+ ...config,
588
+ token,
589
+ csrfToken,
590
+ webBaseUrl
591
+ };
592
+ if (authCookie) nextConfig.authCookie = authCookie;
593
+ if (csrfCookie) nextConfig.csrfCookie = csrfCookie;
594
+ writeConfig(nextConfig);
595
+ console.log(token);
596
+ return;
597
+ }
598
+ if (action === "login") {
599
+ await loginAndPersist(options, readConfig(), isFlagEnabled(options, "raw") ? "raw" : "token");
600
+ return;
601
+ }
602
+ if (action === "status") {
603
+ const config = readConfig();
604
+ const autoLogin = shouldAutoLogin(options, false);
605
+ let token = resolveToken(options, config);
606
+ if (!token) if (autoLogin) token = await loginAndPersist(options, config, "silent");
607
+ else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
608
+ const baseUrl = resolveBaseUrl(options, config);
609
+ let response = await postJson("/api/v2/checkin/list", token, baseUrl, {
610
+ page: 1,
611
+ rpp: 1
612
+ });
613
+ if (autoLogin && isTokenExpiredResponse(response.json)) {
614
+ token = await loginAndPersist(options, config, "silent");
615
+ response = await postJson("/api/v2/checkin/list", token, baseUrl, {
616
+ page: 1,
617
+ rpp: 1
618
+ });
619
+ }
620
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
621
+ if (response.json === void 0) {
622
+ console.log("unknown");
623
+ return;
624
+ }
625
+ console.log(isTokenExpiredResponse(response.json) ? "expired" : "valid");
626
+ return;
627
+ }
628
+ if (action === "show") {
629
+ const config = readConfig();
630
+ if (!config.token) throw new Error("No token saved. Use 'kahunas auth set <token>' or set KAHUNAS_TOKEN.");
631
+ console.log(config.token);
632
+ return;
633
+ }
634
+ throw new Error(`Unknown auth action: ${action}`);
635
+ }
636
+
637
+ //#endregion
638
+ //#region src/output.ts
639
+ function printResponse(response, rawOutput) {
640
+ if (rawOutput) {
641
+ console.log(response.text);
642
+ return;
643
+ }
644
+ if (response.json !== void 0) {
645
+ console.log(JSON.stringify(response.json, null, 2));
646
+ return;
647
+ }
648
+ console.log(response.text);
649
+ }
650
+
651
+ //#endregion
652
+ //#region src/commands/checkins.ts
653
+ async function handleCheckins(positionals, options) {
654
+ const action = positionals[0];
655
+ if (!action || action === "help") {
656
+ printUsage();
657
+ return;
658
+ }
659
+ if (action !== "list") throw new Error(`Unknown checkins action: ${action}`);
660
+ const config = readConfig();
661
+ const autoLogin = shouldAutoLogin(options, true);
662
+ let token = resolveToken(options, config);
663
+ if (!token) if (autoLogin) token = await loginAndPersist(options, config, "silent");
664
+ else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
665
+ const baseUrl = resolveBaseUrl(options, config);
666
+ const page = parseNumber(options.page, 1);
667
+ const rpp = parseNumber(options.rpp, 12);
668
+ const rawOutput = isFlagEnabled(options, "raw");
669
+ let response = await postJson("/api/v2/checkin/list", token, baseUrl, {
670
+ page,
671
+ rpp
672
+ });
673
+ if (autoLogin && isTokenExpiredResponse(response.json)) {
674
+ token = await loginAndPersist(options, config, "silent");
675
+ response = await postJson("/api/v2/checkin/list", token, baseUrl, {
676
+ page,
677
+ rpp
678
+ });
679
+ }
680
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
681
+ const userUuid = extractUserUuidFromCheckins(response.json);
682
+ if (userUuid && userUuid !== config.userUuid) writeConfig({
683
+ ...config,
684
+ userUuid
685
+ });
686
+ printResponse(response, rawOutput);
687
+ }
688
+
689
+ //#endregion
690
+ //#region src/events.ts
691
+ function filterWorkoutEvents(payload, programFilter, workoutFilter) {
692
+ if (!Array.isArray(payload)) return [];
693
+ return payload.filter((entry) => {
694
+ if (!entry || typeof entry !== "object") return false;
695
+ const record = entry;
696
+ if (programFilter && record.program !== programFilter) return false;
697
+ if (workoutFilter && record.workout !== workoutFilter) return false;
698
+ return true;
699
+ });
700
+ }
701
+ function sortWorkoutEvents(events) {
702
+ return [...events].sort((a, b) => {
703
+ return (typeof a.start === "string" ? Date.parse(a.start.replace(" ", "T")) : 0) - (typeof b.start === "string" ? Date.parse(b.start.replace(" ", "T")) : 0);
704
+ });
705
+ }
706
+ function enrichWorkoutEvents(events, programDetails) {
707
+ return events.map((entry) => {
708
+ if (!entry || typeof entry !== "object") return entry;
709
+ const record = entry;
710
+ const programUuid = typeof record.program === "string" ? record.program : void 0;
711
+ const program = programUuid ? programDetails[programUuid] : void 0;
712
+ return {
713
+ ...record,
714
+ program_details: program ?? null
715
+ };
716
+ });
717
+ }
718
+
719
+ //#endregion
720
+ //#region src/commands/workout.ts
721
+ async function handleWorkout(positionals, options) {
722
+ const action = positionals[0];
723
+ if (!action || action === "help") {
724
+ printUsage();
725
+ return;
726
+ }
727
+ const config = readConfig();
728
+ const autoLogin = shouldAutoLogin(options, true);
729
+ let token = resolveToken(options, config);
730
+ const ensureToken = async () => {
731
+ if (!token) if (autoLogin) token = await loginAndPersist(options, config, "silent");
732
+ else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
733
+ return token;
734
+ };
735
+ const baseUrl = resolveBaseUrl(options, config);
736
+ const rawOutput = isFlagEnabled(options, "raw");
737
+ const page = parseNumber(options.page, 1);
738
+ const rpp = parseNumber(options.rpp, 12);
739
+ const listRpp = action === "latest" && options.rpp === void 0 ? 100 : rpp;
740
+ const fetchList = async () => {
741
+ await ensureToken();
742
+ const url = new URL("/api/v1/workoutprogram", baseUrl);
743
+ if (page) url.searchParams.set("page", String(page));
744
+ if (listRpp) url.searchParams.set("rpp", String(listRpp));
745
+ let response = await getWithAuth(url.pathname + url.search, token, baseUrl);
746
+ if (autoLogin && isTokenExpiredResponse(response.json)) {
747
+ token = await loginAndPersist(options, config, "silent");
748
+ response = await getWithAuth(url.pathname + url.search, token, baseUrl);
749
+ }
750
+ const cache = readWorkoutCache();
751
+ const plans = extractWorkoutPlans(response.json);
752
+ const merged = cache ? mergeWorkoutPlans(plans, cache.plans) : plans;
753
+ return {
754
+ response,
755
+ plans: merged,
756
+ cache
757
+ };
758
+ };
759
+ if (action === "list") {
760
+ const { response, plans, cache } = await fetchList();
761
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
762
+ if (rawOutput) {
763
+ printResponse(response, rawOutput);
764
+ return;
765
+ }
766
+ const output = {
767
+ source: cache ? "api+cache" : "api",
768
+ cache: cache ? {
769
+ updated_at: cache.updatedAt,
770
+ count: cache.plans.length,
771
+ path: WORKOUT_CACHE_PATH
772
+ } : void 0,
773
+ data: { workout_plan: plans }
774
+ };
775
+ console.log(JSON.stringify(output, null, 2));
776
+ return;
777
+ }
778
+ if (action === "pick") {
779
+ const { response, plans } = await fetchList();
780
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
781
+ if (plans.length === 0) throw new Error("No workout programs found.");
782
+ if (!rawOutput) {
783
+ console.log("Pick a workout program:");
784
+ plans.forEach((plan, index) => {
785
+ console.log(`${index + 1}) ${formatWorkoutSummary(plan)}`);
786
+ });
787
+ }
788
+ const answer = await askQuestion(`Enter number (1-${plans.length}): `);
789
+ const selection = Number.parseInt(answer, 10);
790
+ if (Number.isNaN(selection) || selection < 1 || selection > plans.length) throw new Error("Invalid selection.");
791
+ const chosen = plans[selection - 1];
792
+ if (!chosen.uuid) throw new Error("Selected workout is missing a uuid.");
793
+ const csrfToken$1 = resolveCsrfToken(options, config);
794
+ let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
795
+ if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
796
+ token = await loginAndPersist(options, config, "silent");
797
+ responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
798
+ }
799
+ if (!responseProgram$1.ok) throw new Error(`HTTP ${responseProgram$1.status}: ${responseProgram$1.text}`);
800
+ printResponse(responseProgram$1, rawOutput);
801
+ return;
802
+ }
803
+ if (action === "latest") {
804
+ const { response, plans } = await fetchList();
805
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
806
+ if (plans.length === 0) throw new Error("No workout programs found.");
807
+ const chosen = pickLatestWorkout(plans);
808
+ if (!chosen || !chosen.uuid) throw new Error("Latest workout is missing a uuid.");
809
+ const csrfToken$1 = resolveCsrfToken(options, config);
810
+ let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
811
+ if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
812
+ token = await loginAndPersist(options, config, "silent");
813
+ responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
814
+ }
815
+ if (!responseProgram$1.ok) throw new Error(`HTTP ${responseProgram$1.status}: ${responseProgram$1.text}`);
816
+ printResponse(responseProgram$1, rawOutput);
817
+ return;
818
+ }
819
+ if (action === "events") {
820
+ const baseWebUrl = resolveWebBaseUrl(options, config);
821
+ const webOrigin = new URL(baseWebUrl).origin;
822
+ const timezone = options.timezone ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "Europe/London";
823
+ let userUuid = resolveUserUuid(options, config);
824
+ if (!userUuid) throw new Error("Missing user uuid. Use --user or set KAHUNAS_USER_UUID.");
825
+ if (userUuid !== config.userUuid) writeConfig({
826
+ ...config,
827
+ userUuid
828
+ });
829
+ const minimal = isFlagEnabled(options, "minimal");
830
+ let csrfToken$1 = resolveCsrfToken(options, config);
831
+ let csrfCookie = resolveCsrfCookie(options, config);
832
+ let authCookie = resolveAuthCookie(options, config);
833
+ let effectiveCsrfToken = csrfCookie ?? csrfToken$1;
834
+ let cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
835
+ if ((!csrfToken$1 || !cookieHeader || !authCookie) && autoLogin) {
836
+ await loginAndPersist(options, config, "silent");
837
+ const refreshed = readConfig();
838
+ csrfToken$1 = resolveCsrfToken(options, refreshed);
839
+ csrfCookie = resolveCsrfCookie(options, refreshed);
840
+ authCookie = resolveAuthCookie(options, refreshed);
841
+ effectiveCsrfToken = csrfCookie ?? csrfToken$1;
842
+ cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
843
+ }
844
+ if (!effectiveCsrfToken) throw new Error("Missing CSRF token. Run 'kahunas auth login' and try again.");
845
+ if (!cookieHeader) throw new Error("Missing cookies. Run 'kahunas auth login' and try again.");
846
+ const url = new URL(`/coach/clients/calendar/getEvent/${userUuid}`, webOrigin);
847
+ url.searchParams.set("timezone", timezone);
848
+ const body = new URLSearchParams();
849
+ body.set("csrf_kahunas_token", effectiveCsrfToken);
850
+ body.set("filter", options.filter ?? "");
851
+ let response = await fetch(url.toString(), {
852
+ method: "POST",
853
+ headers: {
854
+ accept: "*/*",
855
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
856
+ cookie: cookieHeader,
857
+ origin: webOrigin,
858
+ referer: `${webOrigin}/dashboard`,
859
+ "x-requested-with": "XMLHttpRequest"
860
+ },
861
+ body: body.toString()
862
+ });
863
+ let text = await response.text();
864
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`);
865
+ if (autoLogin && isLikelyLoginHtml(text)) {
866
+ await loginAndPersist(options, config, "silent");
867
+ const refreshed = readConfig();
868
+ csrfToken$1 = resolveCsrfToken(options, refreshed);
869
+ csrfCookie = resolveCsrfCookie(options, refreshed);
870
+ authCookie = resolveAuthCookie(options, refreshed);
871
+ effectiveCsrfToken = csrfCookie ?? csrfToken$1;
872
+ cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
873
+ if (!effectiveCsrfToken || !cookieHeader) throw new Error("Login required. Run 'kahunas auth login' and try again.");
874
+ const retry = await fetch(url.toString(), {
875
+ method: "POST",
876
+ headers: {
877
+ accept: "*/*",
878
+ "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
879
+ cookie: cookieHeader,
880
+ origin: webOrigin,
881
+ referer: `${webOrigin}/dashboard`,
882
+ "x-requested-with": "XMLHttpRequest"
883
+ },
884
+ body: body.toString()
885
+ });
886
+ text = await retry.text();
887
+ if (!retry.ok) throw new Error(`HTTP ${retry.status}: ${text}`);
888
+ }
889
+ if (rawOutput) {
890
+ console.log(text);
891
+ return;
892
+ }
893
+ const payload = parseJsonText(text);
894
+ if (!Array.isArray(payload)) {
895
+ console.log(text);
896
+ return;
897
+ }
898
+ const sorted = sortWorkoutEvents(filterWorkoutEvents(payload, options.program, options.workout));
899
+ if (minimal) {
900
+ console.log(JSON.stringify(sorted, null, 2));
901
+ return;
902
+ }
903
+ let programIndex;
904
+ let plans = readWorkoutCache()?.plans ?? [];
905
+ try {
906
+ await ensureToken();
907
+ const listUrl = new URL("/api/v1/workoutprogram", baseUrl);
908
+ listUrl.searchParams.set("page", "1");
909
+ listUrl.searchParams.set("rpp", "100");
910
+ let listResponse = await getWithAuth(listUrl.pathname + listUrl.search, token, baseUrl);
911
+ if (autoLogin && isTokenExpiredResponse(listResponse.json)) {
912
+ token = await loginAndPersist(options, config, "silent");
913
+ listResponse = await getWithAuth(listUrl.pathname + listUrl.search, token, baseUrl);
914
+ }
915
+ if (listResponse.ok) plans = mergeWorkoutPlans(extractWorkoutPlans(listResponse.json), plans);
916
+ } catch {}
917
+ if (plans.length > 0) programIndex = buildWorkoutPlanIndex(plans);
918
+ const programDetails = {};
919
+ const programIds = Array.from(new Set(sorted.map((entry) => {
920
+ if (!entry || typeof entry !== "object") return;
921
+ const record = entry;
922
+ return typeof record.program === "string" ? record.program : void 0;
923
+ }).filter((value) => Boolean(value))));
924
+ for (const programId$1 of programIds) {
925
+ try {
926
+ await ensureToken();
927
+ let responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, programId$1, effectiveCsrfToken);
928
+ if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
929
+ token = await loginAndPersist(options, config, "silent");
930
+ responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, programId$1, effectiveCsrfToken);
931
+ }
932
+ if (responseProgram$1.ok && responseProgram$1.json && typeof responseProgram$1.json === "object") {
933
+ const programPayload = responseProgram$1.json;
934
+ const data = programPayload.data;
935
+ if (data && typeof data === "object") {
936
+ const plan = data.workout_plan;
937
+ if (plan) {
938
+ programDetails[programId$1] = plan;
939
+ continue;
940
+ }
941
+ }
942
+ programDetails[programId$1] = programPayload;
943
+ continue;
944
+ }
945
+ } catch {}
946
+ programDetails[programId$1] = programIndex?.[programId$1] ?? null;
947
+ }
948
+ const enriched = enrichWorkoutEvents(sorted, programDetails);
949
+ console.log(JSON.stringify(enriched, null, 2));
950
+ return;
951
+ }
952
+ if (action === "sync") {
953
+ const captured = await captureWorkoutsFromBrowser(options, config);
954
+ const nextConfig = { ...config };
955
+ if (captured.token) nextConfig.token = captured.token;
956
+ if (captured.csrfToken) nextConfig.csrfToken = captured.csrfToken;
957
+ if (captured.webBaseUrl) nextConfig.webBaseUrl = captured.webBaseUrl;
958
+ if (captured.cookieHeader) nextConfig.authCookie = captured.cookieHeader;
959
+ if (captured.csrfCookie) nextConfig.csrfCookie = captured.csrfCookie;
960
+ writeConfig(nextConfig);
961
+ const cache = writeWorkoutCache(captured.plans);
962
+ console.log(JSON.stringify({
963
+ message: "Workout programs synced",
964
+ cache: {
965
+ updated_at: cache.updatedAt,
966
+ count: cache.plans.length,
967
+ path: WORKOUT_CACHE_PATH
968
+ }
969
+ }, null, 2));
970
+ return;
971
+ }
972
+ if (action !== "program") throw new Error(`Unknown workout action: ${action}`);
973
+ const programId = positionals[1];
974
+ if (!programId) throw new Error("Missing workout program id.");
975
+ const ensuredToken = await ensureToken();
976
+ const csrfToken = resolveCsrfToken(options, config);
977
+ let responseProgram = await fetchWorkoutProgram(ensuredToken, baseUrl, programId, csrfToken);
978
+ if (autoLogin && isTokenExpiredResponse(responseProgram.json)) {
979
+ token = await loginAndPersist(options, config, "silent");
980
+ responseProgram = await fetchWorkoutProgram(token, baseUrl, programId, csrfToken);
981
+ }
982
+ if (!responseProgram.ok) throw new Error(`HTTP ${responseProgram.status}: ${responseProgram.text}`);
983
+ printResponse(responseProgram, rawOutput);
984
+ }
985
+
986
+ //#endregion
987
+ //#region src/cli.ts
988
+ async function main() {
989
+ const { positionals, options } = parseArgs(process.argv.slice(2));
990
+ if (positionals.length === 0 || isFlagEnabled(options, "help")) {
991
+ printUsage();
992
+ return;
993
+ }
994
+ const command = positionals[0];
995
+ const rest = positionals.slice(1);
996
+ switch (command) {
997
+ case "auth":
998
+ await handleAuth(rest, options);
999
+ return;
1000
+ case "checkins":
1001
+ await handleCheckins(rest, options);
1002
+ return;
1003
+ case "workout":
1004
+ await handleWorkout(rest, options);
1005
+ return;
1006
+ case "help":
1007
+ printUsage();
1008
+ return;
1009
+ default: throw new Error(`Unknown command: ${command}`);
1010
+ }
1011
+ }
1012
+ main().catch((error) => {
1013
+ const message = error instanceof Error ? error.message : String(error);
1014
+ console.error(message);
1015
+ process.exit(1);
1016
+ });
1017
+
1018
+ //#endregion
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "kahunas-cli",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "dist/cli.js",
6
+ "bin": {
7
+ "kahunas": "dist/cli.js"
8
+ },
9
+ "keywords": [],
10
+ "author": "Nima Karimi",
11
+ "license": "ISC",
12
+ "files": [
13
+ "dist/cli.js",
14
+ "README.md"
15
+ ],
16
+ "dependencies": {
17
+ "playwright": "^1.49.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.10.5",
21
+ "rolldown": "1.0.0-beta.59",
22
+ "typescript": "^5.7.3",
23
+ "vitest": "^1.6.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "scripts": {
29
+ "build": "rolldown src/cli.ts --file dist/cli.js --format cjs --platform node --external playwright --tsconfig tsconfig.json",
30
+ "kahunas": "node dist/cli.js",
31
+ "lint": "pnpm typecheck",
32
+ "start": "node dist/cli.js",
33
+ "test": "vitest run",
34
+ "typecheck": "tsc -p tsconfig.json --noEmit"
35
+ }
36
+ }