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.
- package/README.md +165 -0
- package/dist/cli.js +1018 -0
- 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
|
+

|
|
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
|
+
}
|