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.
Files changed (3) hide show
  1. package/README.md +9 -139
  2. package/dist/cli.js +287 -114
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,77 +1,17 @@
1
1
  # Kahunas CLI
2
2
 
3
- A TypeScript CLI for Kahunas (kahunas.io) to fetch check-ins and workouts.
4
-
5
- ![Kahunas CLI screenshot](https://unpkg.com/kahunas-cli@latest/ai-screenshot.png)
3
+ Fetch workouts from Kahunas and preview them locally.
6
4
 
7
5
  ## Quick start
8
6
 
9
- 1) Install dependencies and build:
7
+ 1) Install and build:
10
8
 
11
9
  ```bash
12
10
  pnpm install
13
11
  pnpm build
14
12
  ```
15
13
 
16
- 2) Fetch data (browser login runs automatically on first run, headless by default):
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
- Keep this file private; it contains credentials.
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
- Or:
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
- ## Flags
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
- ## Notes
34
+ ## Advanced usage
164
35
 
165
- - This CLI uses the same APIs the web app uses; tokens can expire quickly.
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: process.stdout
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
- if (!enabled) return;
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 resolveStoredAuth() {
490
- const auth = readAuthConfig();
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
- if (isLikelyAuthToken(candidate)) observedToken = candidate;
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
- if (isLikelyAuthToken(candidate)) observedToken = candidate;
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 = extractJwtExpiry(result.token) ?? null;
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
- console.log(`kahunas - CLI for Kahunas API\n\nUsage:\n kahunas checkins list [--raw]\n kahunas workout list [--raw]\n kahunas workout pick [--raw]\n kahunas workout latest [--raw]\n kahunas workout events [--minimal] [--full] [--debug-preview] [--raw]\n kahunas workout serve\n kahunas serve\n kahunas workout sync\n kahunas sync\n kahunas workout program <id> [--raw]\n\nConfig:\n ${CONFIG_PATH}`);
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
- console.log("Pick a workout program:");
2406
+ logPlain(formatHeading("Pick a workout program:"));
2171
2407
  plans.forEach((plan, index) => {
2172
- console.log(`${index + 1}) ${formatWorkoutSummary(plan)}`);
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
- const host = "127.0.0.1";
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 captured = await captureWorkoutsFromBrowser(options, config);
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) nextConfig.token = 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
- const message = error instanceof Error ? error.message : String(error);
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.2.0",
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": {