kahunas-cli 1.2.0 → 1.4.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 +9 -139
- package/dist/cli.js +287 -114
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,77 +1,17 @@
|
|
|
1
1
|
# Kahunas CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-

|
|
3
|
+
Fetch workouts from Kahunas and preview them locally.
|
|
6
4
|
|
|
7
5
|
## Quick start
|
|
8
6
|
|
|
9
|
-
1) Install
|
|
7
|
+
1) Install and build:
|
|
10
8
|
|
|
11
9
|
```bash
|
|
12
10
|
pnpm install
|
|
13
11
|
pnpm build
|
|
14
12
|
```
|
|
15
13
|
|
|
16
|
-
2)
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
pnpm kahunas checkins list
|
|
20
|
-
pnpm kahunas workout list
|
|
21
|
-
pnpm kahunas workout pick
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
You can also run without installing globally:
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
npx kahunas-cli checkins list
|
|
28
|
-
npx kahunas-cli workout events
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
## Commands
|
|
32
|
-
|
|
33
|
-
### Check-ins
|
|
34
|
-
|
|
35
|
-
- `kahunas checkins list`
|
|
36
|
-
- Lists recent check-ins.
|
|
37
|
-
|
|
38
|
-
### Workouts
|
|
39
|
-
|
|
40
|
-
- `kahunas workout list`
|
|
41
|
-
- Lists workout programs.
|
|
42
|
-
- `kahunas workout pick`
|
|
43
|
-
- Shows a numbered list and lets you choose a program.
|
|
44
|
-
- `kahunas workout latest`
|
|
45
|
-
- Loads the most recently updated program.
|
|
46
|
-
- `kahunas workout events`
|
|
47
|
-
- Lists workout log events with dates and a human-friendly workout summary (from the calendar endpoint).
|
|
48
|
-
- `kahunas workout serve`
|
|
49
|
-
- Starts a local dev server with a workout preview page and a JSON endpoint that matches the CLI output.
|
|
50
|
-
- `kahunas workout program <id>`
|
|
51
|
-
- Fetches a program by UUID.
|
|
52
|
-
|
|
53
|
-
### Workout sync (browser capture)
|
|
54
|
-
|
|
55
|
-
If the API list is missing a program you see in the web UI, run:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
pnpm kahunas sync
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
Or:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
pnpm kahunas workout sync
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
This runs a browser session (headless by default). You log in, then navigate to your workouts page. After you press Enter, the CLI captures the workout list from network responses and writes a cache:
|
|
68
|
-
|
|
69
|
-
- `~/.config/kahunas/workouts.json`
|
|
70
|
-
|
|
71
|
-
`workout list`, `workout pick`, and `workout latest` automatically merge the API list with this cache.
|
|
72
|
-
Raw output (`--raw`) prints the API response only.
|
|
73
|
-
|
|
74
|
-
If you add `~/.config/kahunas/auth.json`, the browser flow will attempt an automatic login and open your workouts page before capturing. Example:
|
|
14
|
+
2) Optional: add auto-login credentials at `~/.config/kahunas/auth.json`:
|
|
75
15
|
|
|
76
16
|
```json
|
|
77
17
|
{
|
|
@@ -80,87 +20,17 @@ If you add `~/.config/kahunas/auth.json`, the browser flow will attempt an autom
|
|
|
80
20
|
}
|
|
81
21
|
```
|
|
82
22
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
Optional fields:
|
|
86
|
-
|
|
87
|
-
- `username` (use instead of `email`)
|
|
88
|
-
- `loginPath` (default: `/dashboard`)
|
|
89
|
-
|
|
90
|
-
If auto-capture does not find workouts, the CLI falls back to the manual prompt.
|
|
91
|
-
|
|
92
|
-
### Workout events (dates)
|
|
93
|
-
|
|
94
|
-
To see when workouts happened, the calendar endpoint returns log events with timestamps. The CLI returns the latest event summarized into a human-friendly structure (total volume sets, exercises, supersets). Use `--full` to return the full program payload (best effort; falls back to cached summary if needed).
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
pnpm kahunas workout events
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
Use `--minimal` to return the raw event objects without program enrichment. Use `--full` for full enriched output. Use `--debug-preview` to log where preview HTML was discovered (stderr only).
|
|
101
|
-
|
|
102
|
-
If the user UUID is missing, `workout events` will attempt to discover it from check-ins and save it.
|
|
103
|
-
|
|
104
|
-
### Workout preview server
|
|
105
|
-
|
|
106
|
-
Run a local dev server to preview workouts in a browser:
|
|
23
|
+
3) Sync workouts, then run the preview server:
|
|
107
24
|
|
|
108
25
|
```bash
|
|
26
|
+
pnpm kahunas sync
|
|
109
27
|
pnpm kahunas serve
|
|
110
28
|
```
|
|
111
29
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
```bash
|
|
115
|
-
pnpm kahunas workout serve
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
The HTML page is available at `http://127.0.0.1:3000` and the JSON endpoint is at `http://127.0.0.1:3000/api/workout`.
|
|
119
|
-
The JSON response matches the CLI output for `workout events`, so there is only one data shape to maintain.
|
|
120
|
-
|
|
121
|
-
Use `?day=<index>` to switch the selected workout day tab in the browser.
|
|
122
|
-
|
|
123
|
-
## Auto-login
|
|
124
|
-
|
|
125
|
-
Most commands auto-login if a token is missing or expired. This runs a browser session and saves session details in `~/.config/kahunas/config.json` (headless by default). If `~/.config/kahunas/auth.json` is present, the login step is automated.
|
|
126
|
-
|
|
127
|
-
## Debug logging
|
|
128
|
-
|
|
129
|
-
Set `debug` to `true` in `~/.config/kahunas/config.json` to enable extra logs on stderr (includes workout preview debug output).
|
|
130
|
-
|
|
131
|
-
## Headless mode
|
|
132
|
-
|
|
133
|
-
Set `headless` to `false` in `~/.config/kahunas/config.json` to show the Playwright browser. Defaults to `true`.
|
|
30
|
+
Open `http://127.0.0.1:3000`.
|
|
134
31
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
- `--raw` prints raw API responses (no formatting).
|
|
138
|
-
|
|
139
|
-
## Playwright
|
|
140
|
-
|
|
141
|
-
If Playwright did not download a browser during install:
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
pnpm exec playwright install chromium
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
## Testing
|
|
148
|
-
|
|
149
|
-
Run the unit tests with:
|
|
150
|
-
|
|
151
|
-
```bash
|
|
152
|
-
pnpm test
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
## Publishing
|
|
156
|
-
|
|
157
|
-
The npm package is built from `dist/cli.js`. Publishing runs the build automatically:
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
pnpm publish
|
|
161
|
-
```
|
|
32
|
+
If `auth.json` is missing, `sync` will prompt for credentials and save them after a successful login.
|
|
162
33
|
|
|
163
|
-
##
|
|
34
|
+
## Advanced usage
|
|
164
35
|
|
|
165
|
-
|
|
166
|
-
- Re-run any command (or `workout sync`) to refresh login when needed.
|
|
36
|
+
See `docs/advanced.md` for all commands, flags, and configuration options.
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __getProtoOf = Object.getPrototypeOf;
|
|
8
8
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
9
10
|
var __copyProps = (to, from, except, desc) => {
|
|
10
11
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
12
|
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
@@ -94,6 +95,11 @@ function readAuthConfig() {
|
|
|
94
95
|
throw new Error(`Invalid JSON in ${AUTH_PATH}.`);
|
|
95
96
|
}
|
|
96
97
|
}
|
|
98
|
+
function writeAuthConfig(auth) {
|
|
99
|
+
const dir = node_path.dirname(AUTH_PATH);
|
|
100
|
+
node_fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
node_fs.writeFileSync(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, "utf-8");
|
|
102
|
+
}
|
|
97
103
|
function writeConfig(config) {
|
|
98
104
|
const dir = node_path.dirname(CONFIG_PATH);
|
|
99
105
|
node_fs.mkdirSync(dir, { recursive: true });
|
|
@@ -190,6 +196,14 @@ function extractJwtExpiry(token) {
|
|
|
190
196
|
return;
|
|
191
197
|
}
|
|
192
198
|
}
|
|
199
|
+
const DEFAULT_TOKEN_TTL_MS = 7200 * 1e3;
|
|
200
|
+
function resolveTokenExpiry(token, tokenUpdatedAt) {
|
|
201
|
+
const jwtExpiry = extractJwtExpiry(token);
|
|
202
|
+
if (jwtExpiry) return jwtExpiry;
|
|
203
|
+
const updatedAt = Date.parse(tokenUpdatedAt);
|
|
204
|
+
if (!Number.isFinite(updatedAt)) return;
|
|
205
|
+
return new Date(updatedAt + DEFAULT_TOKEN_TTL_MS).toISOString();
|
|
206
|
+
}
|
|
193
207
|
function decodeBase64Url(value) {
|
|
194
208
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
195
209
|
const padding = normalized.length % 4;
|
|
@@ -313,13 +327,108 @@ function extractUserUuidFromCheckins(payload) {
|
|
|
313
327
|
return typeof candidate === "string" ? candidate : void 0;
|
|
314
328
|
}
|
|
315
329
|
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js
|
|
332
|
+
var require_picocolors = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
333
|
+
let p = process || {}, argv = p.argv || [], env = p.env || {};
|
|
334
|
+
let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
|
|
335
|
+
let formatter = (open, close, replace = open) => (input) => {
|
|
336
|
+
let string = "" + input, index = string.indexOf(close, open.length);
|
|
337
|
+
return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
|
|
338
|
+
};
|
|
339
|
+
let replaceClose = (string, close, replace, index) => {
|
|
340
|
+
let result = "", cursor = 0;
|
|
341
|
+
do {
|
|
342
|
+
result += string.substring(cursor, index) + replace;
|
|
343
|
+
cursor = index + close.length;
|
|
344
|
+
index = string.indexOf(close, cursor);
|
|
345
|
+
} while (~index);
|
|
346
|
+
return result + string.substring(cursor);
|
|
347
|
+
};
|
|
348
|
+
let createColors = (enabled = isColorSupported) => {
|
|
349
|
+
let f = enabled ? formatter : () => String;
|
|
350
|
+
return {
|
|
351
|
+
isColorSupported: enabled,
|
|
352
|
+
reset: f("\x1B[0m", "\x1B[0m"),
|
|
353
|
+
bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
|
|
354
|
+
dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
|
|
355
|
+
italic: f("\x1B[3m", "\x1B[23m"),
|
|
356
|
+
underline: f("\x1B[4m", "\x1B[24m"),
|
|
357
|
+
inverse: f("\x1B[7m", "\x1B[27m"),
|
|
358
|
+
hidden: f("\x1B[8m", "\x1B[28m"),
|
|
359
|
+
strikethrough: f("\x1B[9m", "\x1B[29m"),
|
|
360
|
+
black: f("\x1B[30m", "\x1B[39m"),
|
|
361
|
+
red: f("\x1B[31m", "\x1B[39m"),
|
|
362
|
+
green: f("\x1B[32m", "\x1B[39m"),
|
|
363
|
+
yellow: f("\x1B[33m", "\x1B[39m"),
|
|
364
|
+
blue: f("\x1B[34m", "\x1B[39m"),
|
|
365
|
+
magenta: f("\x1B[35m", "\x1B[39m"),
|
|
366
|
+
cyan: f("\x1B[36m", "\x1B[39m"),
|
|
367
|
+
white: f("\x1B[37m", "\x1B[39m"),
|
|
368
|
+
gray: f("\x1B[90m", "\x1B[39m"),
|
|
369
|
+
bgBlack: f("\x1B[40m", "\x1B[49m"),
|
|
370
|
+
bgRed: f("\x1B[41m", "\x1B[49m"),
|
|
371
|
+
bgGreen: f("\x1B[42m", "\x1B[49m"),
|
|
372
|
+
bgYellow: f("\x1B[43m", "\x1B[49m"),
|
|
373
|
+
bgBlue: f("\x1B[44m", "\x1B[49m"),
|
|
374
|
+
bgMagenta: f("\x1B[45m", "\x1B[49m"),
|
|
375
|
+
bgCyan: f("\x1B[46m", "\x1B[49m"),
|
|
376
|
+
bgWhite: f("\x1B[47m", "\x1B[49m"),
|
|
377
|
+
blackBright: f("\x1B[90m", "\x1B[39m"),
|
|
378
|
+
redBright: f("\x1B[91m", "\x1B[39m"),
|
|
379
|
+
greenBright: f("\x1B[92m", "\x1B[39m"),
|
|
380
|
+
yellowBright: f("\x1B[93m", "\x1B[39m"),
|
|
381
|
+
blueBright: f("\x1B[94m", "\x1B[39m"),
|
|
382
|
+
magentaBright: f("\x1B[95m", "\x1B[39m"),
|
|
383
|
+
cyanBright: f("\x1B[96m", "\x1B[39m"),
|
|
384
|
+
whiteBright: f("\x1B[97m", "\x1B[39m"),
|
|
385
|
+
bgBlackBright: f("\x1B[100m", "\x1B[49m"),
|
|
386
|
+
bgRedBright: f("\x1B[101m", "\x1B[49m"),
|
|
387
|
+
bgGreenBright: f("\x1B[102m", "\x1B[49m"),
|
|
388
|
+
bgYellowBright: f("\x1B[103m", "\x1B[49m"),
|
|
389
|
+
bgBlueBright: f("\x1B[104m", "\x1B[49m"),
|
|
390
|
+
bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
|
|
391
|
+
bgCyanBright: f("\x1B[106m", "\x1B[49m"),
|
|
392
|
+
bgWhiteBright: f("\x1B[107m", "\x1B[49m")
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
module.exports = createColors();
|
|
396
|
+
module.exports.createColors = createColors;
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/logger.ts
|
|
401
|
+
var import_picocolors = /* @__PURE__ */ __toESM(require_picocolors());
|
|
402
|
+
function formatLabel(label, color) {
|
|
403
|
+
return import_picocolors.default.bold(color(label));
|
|
404
|
+
}
|
|
405
|
+
function logInfo(message) {
|
|
406
|
+
console.log(`${formatLabel("info", import_picocolors.default.cyan)} ${message}`);
|
|
407
|
+
}
|
|
408
|
+
function logError(message) {
|
|
409
|
+
console.error(`${formatLabel("error", import_picocolors.default.red)} ${message}`);
|
|
410
|
+
}
|
|
411
|
+
function logDebug(enabled, message) {
|
|
412
|
+
if (!enabled) return;
|
|
413
|
+
console.error(`${formatLabel("debug", import_picocolors.default.gray)} ${message}`);
|
|
414
|
+
}
|
|
415
|
+
function logPlain(message) {
|
|
416
|
+
console.log(message);
|
|
417
|
+
}
|
|
418
|
+
function formatHeading(message) {
|
|
419
|
+
return import_picocolors.default.bold(message);
|
|
420
|
+
}
|
|
421
|
+
function formatDim(message) {
|
|
422
|
+
return import_picocolors.default.dim(message);
|
|
423
|
+
}
|
|
424
|
+
|
|
316
425
|
//#endregion
|
|
317
426
|
//#region src/utils.ts
|
|
318
|
-
function askQuestion(prompt) {
|
|
427
|
+
function askQuestion(prompt, output = process.stdout) {
|
|
319
428
|
return new Promise((resolve) => {
|
|
320
429
|
const rl = node_readline.createInterface({
|
|
321
430
|
input: process.stdin,
|
|
322
|
-
output
|
|
431
|
+
output
|
|
323
432
|
});
|
|
324
433
|
rl.question(prompt, (answer) => {
|
|
325
434
|
rl.close();
|
|
@@ -327,12 +436,29 @@ function askQuestion(prompt) {
|
|
|
327
436
|
});
|
|
328
437
|
});
|
|
329
438
|
}
|
|
330
|
-
function waitForEnter(prompt) {
|
|
331
|
-
return askQuestion(prompt).then(() => void 0);
|
|
439
|
+
function waitForEnter(prompt, output = process.stdout) {
|
|
440
|
+
return askQuestion(prompt, output).then(() => void 0);
|
|
441
|
+
}
|
|
442
|
+
function askHiddenQuestion(prompt, output = process.stderr) {
|
|
443
|
+
return new Promise((resolve) => {
|
|
444
|
+
const rl = node_readline.createInterface({
|
|
445
|
+
input: process.stdin,
|
|
446
|
+
output,
|
|
447
|
+
terminal: true
|
|
448
|
+
});
|
|
449
|
+
const writable = rl;
|
|
450
|
+
const originalWrite = writable._writeToOutput.bind(rl);
|
|
451
|
+
writable._writeToOutput = (value) => {
|
|
452
|
+
if (value.includes("\n") || value.includes("\r")) originalWrite(value);
|
|
453
|
+
};
|
|
454
|
+
rl.question(prompt, (answer) => {
|
|
455
|
+
rl.close();
|
|
456
|
+
resolve(answer.trim());
|
|
457
|
+
});
|
|
458
|
+
});
|
|
332
459
|
}
|
|
333
460
|
function debugLog(enabled, message) {
|
|
334
|
-
|
|
335
|
-
console.error(`[debug] ${message}`);
|
|
461
|
+
logDebug(enabled, message);
|
|
336
462
|
}
|
|
337
463
|
|
|
338
464
|
//#endregion
|
|
@@ -486,8 +612,11 @@ function normalizePath(pathname) {
|
|
|
486
612
|
if (pathname.startsWith("/")) return pathname;
|
|
487
613
|
return `/${pathname}`;
|
|
488
614
|
}
|
|
489
|
-
function
|
|
490
|
-
|
|
615
|
+
function normalizeToken(token) {
|
|
616
|
+
return token.trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").replace(/^bearer\s+/i, "");
|
|
617
|
+
}
|
|
618
|
+
function resolveStoredAuth(override) {
|
|
619
|
+
const auth = override ?? readAuthConfig();
|
|
491
620
|
if (!auth) return;
|
|
492
621
|
const login = auth.username ?? auth.email;
|
|
493
622
|
if (!login || !auth.password) throw new Error(`Invalid auth.json at ${AUTH_PATH}. Expected \"username\" or \"email\" and \"password\".`);
|
|
@@ -608,11 +737,11 @@ async function triggerWorkoutCapture(page, webOrigin, plans, debug) {
|
|
|
608
737
|
debugLog(debug, `Workout capture plans=${plans.length}`);
|
|
609
738
|
return plans.length > 0;
|
|
610
739
|
}
|
|
611
|
-
async function captureWorkoutsFromBrowser(options, config) {
|
|
740
|
+
async function captureWorkoutsFromBrowser(options, config, authOverride) {
|
|
612
741
|
const webBaseUrl = resolveWebBaseUrl(options, config);
|
|
613
742
|
const headless = config.headless ?? true;
|
|
614
743
|
const debug = config.debug === true;
|
|
615
|
-
const storedAuth = resolveStoredAuth();
|
|
744
|
+
const storedAuth = resolveStoredAuth(authOverride);
|
|
616
745
|
const browser = await (await import("playwright")).chromium.launch({ headless });
|
|
617
746
|
const context = await browser.newContext();
|
|
618
747
|
const plans = [];
|
|
@@ -620,7 +749,8 @@ async function captureWorkoutsFromBrowser(options, config) {
|
|
|
620
749
|
let observedToken;
|
|
621
750
|
const recordToken = (candidate) => {
|
|
622
751
|
if (!candidate || observedToken) return;
|
|
623
|
-
|
|
752
|
+
const normalized = normalizeToken(candidate);
|
|
753
|
+
if (isLikelyAuthToken(normalized)) observedToken = normalized;
|
|
624
754
|
};
|
|
625
755
|
const recordPlans = (incoming) => {
|
|
626
756
|
for (const plan of incoming) {
|
|
@@ -659,6 +789,13 @@ async function captureWorkoutsFromBrowser(options, config) {
|
|
|
659
789
|
cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
|
660
790
|
csrfCookie = cookies.find((cookie) => cookie.name === "csrf_kahunas_cookie_token")?.value;
|
|
661
791
|
csrfToken = csrfCookie ?? resolveCsrfToken(options, config);
|
|
792
|
+
if (!observedToken && csrfToken && cookieHeader) try {
|
|
793
|
+
const { token: fetchedToken } = await fetchAuthToken(csrfToken, cookieHeader, webBaseUrl);
|
|
794
|
+
recordToken(fetchedToken);
|
|
795
|
+
debugLog(debug, "Fetched auth token via /get-token.");
|
|
796
|
+
} catch (error) {
|
|
797
|
+
debugLog(debug, `Failed to fetch auth token via /get-token: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
798
|
+
}
|
|
662
799
|
if (plans.length === 0) await page.waitForTimeout(1500);
|
|
663
800
|
} finally {
|
|
664
801
|
await browser.close();
|
|
@@ -682,7 +819,8 @@ async function loginWithBrowser(options, config) {
|
|
|
682
819
|
let observedToken;
|
|
683
820
|
const recordToken = (candidate) => {
|
|
684
821
|
if (!candidate || observedToken) return;
|
|
685
|
-
|
|
822
|
+
const normalized = normalizeToken(candidate);
|
|
823
|
+
if (isLikelyAuthToken(normalized)) observedToken = normalized;
|
|
686
824
|
};
|
|
687
825
|
context.on("request", (request) => {
|
|
688
826
|
recordToken(request.headers()["auth-user-token"]);
|
|
@@ -741,7 +879,7 @@ async function loginWithBrowser(options, config) {
|
|
|
741
879
|
async function loginAndPersist(options, config, outputMode) {
|
|
742
880
|
const result = await loginWithBrowser(options, config);
|
|
743
881
|
const tokenUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
744
|
-
const tokenExpiresAt =
|
|
882
|
+
const tokenExpiresAt = resolveTokenExpiry(result.token, tokenUpdatedAt) ?? null;
|
|
745
883
|
const nextConfig = {
|
|
746
884
|
...config,
|
|
747
885
|
token: result.token,
|
|
@@ -761,7 +899,7 @@ async function loginAndPersist(options, config, outputMode) {
|
|
|
761
899
|
//#endregion
|
|
762
900
|
//#region src/usage.ts
|
|
763
901
|
function printUsage() {
|
|
764
|
-
|
|
902
|
+
logPlain(`${formatHeading("kahunas - CLI for Kahunas API")}\n\nUsage:\n kahunas checkins list [--raw]\n kahunas workout list [--raw]\n kahunas workout pick [--raw]\n kahunas workout latest [--raw]\n kahunas workout events [--minimal] [--full] [--debug-preview] [--raw]\n kahunas workout serve\n kahunas serve\n kahunas workout sync\n kahunas sync\n kahunas workout program <id> [--raw]\n\n${formatDim("Config:")}\n ${CONFIG_PATH}`);
|
|
765
903
|
}
|
|
766
904
|
|
|
767
905
|
//#endregion
|
|
@@ -2123,6 +2261,104 @@ async function handleWorkout(positionals, options) {
|
|
|
2123
2261
|
}
|
|
2124
2262
|
return programDetails;
|
|
2125
2263
|
};
|
|
2264
|
+
const startWorkoutServer = async () => {
|
|
2265
|
+
const host = "127.0.0.1";
|
|
2266
|
+
const port = 3e3;
|
|
2267
|
+
const limit = 1;
|
|
2268
|
+
const cacheTtlMs = 3e4;
|
|
2269
|
+
const loadSummary = async () => {
|
|
2270
|
+
const { text, payload, timezone } = await fetchWorkoutEventsPayload();
|
|
2271
|
+
if (!Array.isArray(payload)) throw new Error(`Unexpected calendar response: ${text.slice(0, 200)}`);
|
|
2272
|
+
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
|
|
2273
|
+
const bounded = limit > 0 ? sorted.slice(-limit) : sorted;
|
|
2274
|
+
const programDetails = await buildProgramDetails(sorted);
|
|
2275
|
+
const formatted = formatWorkoutEventsOutput(bounded, programDetails, {
|
|
2276
|
+
timezone,
|
|
2277
|
+
program: void 0,
|
|
2278
|
+
workout: void 0
|
|
2279
|
+
});
|
|
2280
|
+
const summary = formatted.events[0];
|
|
2281
|
+
const programUuid = summary?.program?.uuid ?? (bounded[0] && typeof bounded[0] === "object" ? bounded[0].program : void 0);
|
|
2282
|
+
return {
|
|
2283
|
+
formatted,
|
|
2284
|
+
days: summarizeWorkoutProgramDays(programUuid ? programDetails[programUuid] : void 0),
|
|
2285
|
+
summary,
|
|
2286
|
+
timezone
|
|
2287
|
+
};
|
|
2288
|
+
};
|
|
2289
|
+
let cached;
|
|
2290
|
+
let summaryInFlight = null;
|
|
2291
|
+
const getSummary = async (forceRefresh) => {
|
|
2292
|
+
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.data;
|
|
2293
|
+
if (!forceRefresh && summaryInFlight) return summaryInFlight;
|
|
2294
|
+
const pending = loadSummary().finally(() => {
|
|
2295
|
+
summaryInFlight = null;
|
|
2296
|
+
});
|
|
2297
|
+
summaryInFlight = pending;
|
|
2298
|
+
const data = await pending;
|
|
2299
|
+
cached = {
|
|
2300
|
+
data,
|
|
2301
|
+
fetchedAt: Date.now()
|
|
2302
|
+
};
|
|
2303
|
+
return data;
|
|
2304
|
+
};
|
|
2305
|
+
(0, node_http.createServer)(async (req, res) => {
|
|
2306
|
+
try {
|
|
2307
|
+
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
2308
|
+
const wantsRefresh = url.searchParams.get("refresh") === "1";
|
|
2309
|
+
if (url.pathname === "/api/workout") {
|
|
2310
|
+
const data$1 = await getSummary(wantsRefresh);
|
|
2311
|
+
res.statusCode = 200;
|
|
2312
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2313
|
+
res.setHeader("cache-control", "no-store");
|
|
2314
|
+
res.end(JSON.stringify(data$1.formatted, null, 2));
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
if (url.pathname === "/favicon.ico") {
|
|
2318
|
+
res.statusCode = 204;
|
|
2319
|
+
res.end();
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
if (url.pathname !== "/") {
|
|
2323
|
+
res.statusCode = 404;
|
|
2324
|
+
res.end("Not found");
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const data = await getSummary(wantsRefresh);
|
|
2328
|
+
const dayParam = url.searchParams.get("day");
|
|
2329
|
+
const selectedDayIndex = resolveSelectedDayIndex(data.days, data.summary?.workout_day?.day_index, data.summary?.workout_day?.day_label, dayParam);
|
|
2330
|
+
const html = renderWorkoutPage({
|
|
2331
|
+
summary: data.summary,
|
|
2332
|
+
days: data.days,
|
|
2333
|
+
selectedDayIndex,
|
|
2334
|
+
timezone: data.timezone,
|
|
2335
|
+
apiPath: "/api/workout",
|
|
2336
|
+
refreshPath: "/?refresh=1",
|
|
2337
|
+
isLatest: limit === 1
|
|
2338
|
+
});
|
|
2339
|
+
res.statusCode = 200;
|
|
2340
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
2341
|
+
res.setHeader("cache-control", "no-store");
|
|
2342
|
+
res.end(html);
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
res.statusCode = 500;
|
|
2345
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
2346
|
+
res.end(error instanceof Error ? error.message : "Server error");
|
|
2347
|
+
}
|
|
2348
|
+
}).listen(port, host, () => {
|
|
2349
|
+
const cache = readWorkoutCache();
|
|
2350
|
+
const freshConfig = readConfig();
|
|
2351
|
+
const lastSync = cache?.updatedAt ?? "none";
|
|
2352
|
+
const tokenExpiry = freshConfig.tokenExpiresAt ?? "unknown";
|
|
2353
|
+
const tokenUpdatedAt = freshConfig.tokenUpdatedAt ?? "unknown";
|
|
2354
|
+
logInfo(`Local workout server running at http://${host}:${port}`);
|
|
2355
|
+
logInfo(`JSON endpoint at http://${host}:${port}/api/workout`);
|
|
2356
|
+
logInfo(`Config: ${CONFIG_PATH}`);
|
|
2357
|
+
logInfo(`Last workout sync: ${lastSync}`);
|
|
2358
|
+
logInfo(`Token expiry: ${tokenExpiry}`);
|
|
2359
|
+
if (tokenExpiry === "unknown" && tokenUpdatedAt !== "unknown") logInfo(`Token updated at: ${tokenUpdatedAt}`);
|
|
2360
|
+
});
|
|
2361
|
+
};
|
|
2126
2362
|
const resolveSelectedDayIndex = (days, eventDayIndex, eventDayLabel, dayParam) => {
|
|
2127
2363
|
const parseOptionalInt = (value) => {
|
|
2128
2364
|
if (!value) return;
|
|
@@ -2167,9 +2403,9 @@ async function handleWorkout(positionals, options) {
|
|
|
2167
2403
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
|
|
2168
2404
|
if (plans.length === 0) throw new Error("No workout programs found.");
|
|
2169
2405
|
if (!rawOutput) {
|
|
2170
|
-
|
|
2406
|
+
logPlain(formatHeading("Pick a workout program:"));
|
|
2171
2407
|
plans.forEach((plan, index) => {
|
|
2172
|
-
|
|
2408
|
+
logPlain(`${index + 1}) ${formatWorkoutSummary(plan)}`);
|
|
2173
2409
|
});
|
|
2174
2410
|
}
|
|
2175
2411
|
const answer = await askQuestion(`Enter number (1-${plans.length}): `);
|
|
@@ -2248,106 +2484,37 @@ async function handleWorkout(positionals, options) {
|
|
|
2248
2484
|
return;
|
|
2249
2485
|
}
|
|
2250
2486
|
if (action === "serve") {
|
|
2251
|
-
|
|
2252
|
-
const port = 3e3;
|
|
2253
|
-
const limit = 1;
|
|
2254
|
-
const cacheTtlMs = 3e4;
|
|
2255
|
-
const loadSummary = async () => {
|
|
2256
|
-
const { text, payload, timezone } = await fetchWorkoutEventsPayload();
|
|
2257
|
-
if (!Array.isArray(payload)) throw new Error(`Unexpected calendar response: ${text.slice(0, 200)}`);
|
|
2258
|
-
const sorted = sortWorkoutEvents(filterWorkoutEvents(payload));
|
|
2259
|
-
const bounded = limit > 0 ? sorted.slice(-limit) : sorted;
|
|
2260
|
-
const programDetails = await buildProgramDetails(sorted);
|
|
2261
|
-
const formatted = formatWorkoutEventsOutput(bounded, programDetails, {
|
|
2262
|
-
timezone,
|
|
2263
|
-
program: void 0,
|
|
2264
|
-
workout: void 0
|
|
2265
|
-
});
|
|
2266
|
-
const summary = formatted.events[0];
|
|
2267
|
-
const programUuid = summary?.program?.uuid ?? (bounded[0] && typeof bounded[0] === "object" ? bounded[0].program : void 0);
|
|
2268
|
-
return {
|
|
2269
|
-
formatted,
|
|
2270
|
-
days: summarizeWorkoutProgramDays(programUuid ? programDetails[programUuid] : void 0),
|
|
2271
|
-
summary,
|
|
2272
|
-
timezone
|
|
2273
|
-
};
|
|
2274
|
-
};
|
|
2275
|
-
let cached;
|
|
2276
|
-
let summaryInFlight = null;
|
|
2277
|
-
const getSummary = async (forceRefresh) => {
|
|
2278
|
-
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.data;
|
|
2279
|
-
if (!forceRefresh && summaryInFlight) return summaryInFlight;
|
|
2280
|
-
const pending = loadSummary().finally(() => {
|
|
2281
|
-
summaryInFlight = null;
|
|
2282
|
-
});
|
|
2283
|
-
summaryInFlight = pending;
|
|
2284
|
-
const data = await pending;
|
|
2285
|
-
cached = {
|
|
2286
|
-
data,
|
|
2287
|
-
fetchedAt: Date.now()
|
|
2288
|
-
};
|
|
2289
|
-
return data;
|
|
2290
|
-
};
|
|
2291
|
-
(0, node_http.createServer)(async (req, res) => {
|
|
2292
|
-
try {
|
|
2293
|
-
const url = new URL(req.url ?? "/", `http://${host}:${port}`);
|
|
2294
|
-
const wantsRefresh = url.searchParams.get("refresh") === "1";
|
|
2295
|
-
if (url.pathname === "/api/workout") {
|
|
2296
|
-
const data$1 = await getSummary(wantsRefresh);
|
|
2297
|
-
res.statusCode = 200;
|
|
2298
|
-
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2299
|
-
res.setHeader("cache-control", "no-store");
|
|
2300
|
-
res.end(JSON.stringify(data$1.formatted, null, 2));
|
|
2301
|
-
return;
|
|
2302
|
-
}
|
|
2303
|
-
if (url.pathname === "/favicon.ico") {
|
|
2304
|
-
res.statusCode = 204;
|
|
2305
|
-
res.end();
|
|
2306
|
-
return;
|
|
2307
|
-
}
|
|
2308
|
-
if (url.pathname !== "/") {
|
|
2309
|
-
res.statusCode = 404;
|
|
2310
|
-
res.end("Not found");
|
|
2311
|
-
return;
|
|
2312
|
-
}
|
|
2313
|
-
const data = await getSummary(wantsRefresh);
|
|
2314
|
-
const dayParam = url.searchParams.get("day");
|
|
2315
|
-
const selectedDayIndex = resolveSelectedDayIndex(data.days, data.summary?.workout_day?.day_index, data.summary?.workout_day?.day_label, dayParam);
|
|
2316
|
-
const html = renderWorkoutPage({
|
|
2317
|
-
summary: data.summary,
|
|
2318
|
-
days: data.days,
|
|
2319
|
-
selectedDayIndex,
|
|
2320
|
-
timezone: data.timezone,
|
|
2321
|
-
apiPath: "/api/workout",
|
|
2322
|
-
refreshPath: "/?refresh=1",
|
|
2323
|
-
isLatest: limit === 1
|
|
2324
|
-
});
|
|
2325
|
-
res.statusCode = 200;
|
|
2326
|
-
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
2327
|
-
res.setHeader("cache-control", "no-store");
|
|
2328
|
-
res.end(html);
|
|
2329
|
-
} catch (error) {
|
|
2330
|
-
res.statusCode = 500;
|
|
2331
|
-
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
2332
|
-
res.end(error instanceof Error ? error.message : "Server error");
|
|
2333
|
-
}
|
|
2334
|
-
}).listen(port, host, () => {
|
|
2335
|
-
const cache = readWorkoutCache();
|
|
2336
|
-
const freshConfig = readConfig();
|
|
2337
|
-
const lastSync = cache?.updatedAt ?? "none";
|
|
2338
|
-
const tokenExpiry = freshConfig.tokenExpiresAt ?? "unknown";
|
|
2339
|
-
console.log(`Local workout server running at http://${host}:${port}`);
|
|
2340
|
-
console.log(`JSON endpoint at http://${host}:${port}/api/workout`);
|
|
2341
|
-
console.log(`Config: ${CONFIG_PATH}`);
|
|
2342
|
-
console.log(`Last workout sync: ${lastSync}`);
|
|
2343
|
-
console.log(`Token expiry: ${tokenExpiry}`);
|
|
2344
|
-
});
|
|
2487
|
+
await startWorkoutServer();
|
|
2345
2488
|
return;
|
|
2346
2489
|
}
|
|
2347
2490
|
if (action === "sync") {
|
|
2348
|
-
const
|
|
2491
|
+
const authConfig = readAuthConfig();
|
|
2492
|
+
const hasAuthConfig = !!authConfig && !!authConfig.password && (!!authConfig.email || !!authConfig.username);
|
|
2493
|
+
const tokenUpdatedAt = config.tokenUpdatedAt ?? void 0;
|
|
2494
|
+
const tokenExpiry = token && tokenUpdatedAt ? resolveTokenExpiry(token, tokenUpdatedAt) : null;
|
|
2495
|
+
const hasValidToken = !!tokenExpiry && Date.now() < Date.parse(tokenExpiry);
|
|
2496
|
+
let pendingAuth;
|
|
2497
|
+
if (!hasValidToken && !hasAuthConfig) {
|
|
2498
|
+
if (!process.stdin.isTTY) throw new Error("Missing auth credentials. Create ~/.config/kahunas/auth.json or run 'kahunas sync' in a terminal.");
|
|
2499
|
+
const login = await askQuestion("Email or username: ", process.stderr);
|
|
2500
|
+
if (!login) throw new Error("Missing email/username for login.");
|
|
2501
|
+
const password = await askHiddenQuestion("Password: ", process.stderr);
|
|
2502
|
+
if (!password) throw new Error("Missing password for login.");
|
|
2503
|
+
const isEmail = login.includes("@");
|
|
2504
|
+
pendingAuth = {
|
|
2505
|
+
email: isEmail ? login : void 0,
|
|
2506
|
+
username: isEmail ? void 0 : login,
|
|
2507
|
+
password
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
const captured = await captureWorkoutsFromBrowser(options, config, pendingAuth);
|
|
2349
2511
|
const nextConfig = { ...config };
|
|
2350
|
-
if (captured.token)
|
|
2512
|
+
if (captured.token) {
|
|
2513
|
+
nextConfig.token = captured.token;
|
|
2514
|
+
const tokenUpdatedAt$1 = (/* @__PURE__ */ new Date()).toISOString();
|
|
2515
|
+
nextConfig.tokenUpdatedAt = tokenUpdatedAt$1;
|
|
2516
|
+
nextConfig.tokenExpiresAt = resolveTokenExpiry(captured.token, tokenUpdatedAt$1) ?? null;
|
|
2517
|
+
}
|
|
2351
2518
|
if (captured.csrfToken) nextConfig.csrfToken = captured.csrfToken;
|
|
2352
2519
|
if (captured.webBaseUrl) nextConfig.webBaseUrl = captured.webBaseUrl;
|
|
2353
2520
|
if (captured.cookieHeader) nextConfig.authCookie = captured.cookieHeader;
|
|
@@ -2362,6 +2529,13 @@ async function handleWorkout(positionals, options) {
|
|
|
2362
2529
|
path: WORKOUT_CACHE_PATH
|
|
2363
2530
|
}
|
|
2364
2531
|
}, null, 2));
|
|
2532
|
+
if (pendingAuth && captured.token) {
|
|
2533
|
+
writeAuthConfig(pendingAuth);
|
|
2534
|
+
console.error(`Saved credentials to ${AUTH_PATH}`);
|
|
2535
|
+
} else if (pendingAuth) console.error("Login was not detected; credentials were not saved.");
|
|
2536
|
+
if (process.stdin.isTTY) {
|
|
2537
|
+
if ((await askQuestion("Start the preview server now? (y/N): ", process.stderr)).toLowerCase().startsWith("y")) await startWorkoutServer();
|
|
2538
|
+
}
|
|
2365
2539
|
return;
|
|
2366
2540
|
}
|
|
2367
2541
|
if (action !== "program") throw new Error(`Unknown workout action: ${action}`);
|
|
@@ -2408,8 +2582,7 @@ async function main() {
|
|
|
2408
2582
|
}
|
|
2409
2583
|
}
|
|
2410
2584
|
main().catch((error) => {
|
|
2411
|
-
|
|
2412
|
-
console.error(message);
|
|
2585
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
2413
2586
|
process.exit(1);
|
|
2414
2587
|
});
|
|
2415
2588
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kahunas-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"ai-screenshot.png"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"picocolors": "^1.1.1",
|
|
22
23
|
"playwright": "^1.49.1"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|