kahunas-cli 1.0.5 → 1.0.6

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 +23 -0
  2. package/dist/cli.js +592 -83
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -64,6 +64,8 @@ Tokens are saved to:
64
64
  - Loads the most recently updated program.
65
65
  - `kahunas workout events`
66
66
  - Lists workout log events with dates and a human-friendly workout summary (from the calendar endpoint).
67
+ - `kahunas workout serve`
68
+ - Starts a local dev server with a workout preview page and a JSON endpoint that matches the CLI output.
67
69
  - `kahunas workout program <id>`
68
70
  - Fetches a program by UUID.
69
71
 
@@ -112,6 +114,27 @@ If the user UUID is missing, `workout events` will attempt to discover it from c
112
114
  - `KAHUNAS_USER_UUID=...`
113
115
  - `--user <uuid>`
114
116
 
117
+ ### Workout preview server
118
+
119
+ Run a local dev server to preview workouts in a browser:
120
+
121
+ ```bash
122
+ pnpm kahunas -- workout serve
123
+ ```
124
+
125
+ The HTML page is available at `http://127.0.0.1:3000` and the JSON endpoint is at `http://127.0.0.1:3000/api/workout`.
126
+ The JSON response matches the CLI output for `workout events --latest`, so there is only one data shape to maintain.
127
+
128
+ Options:
129
+
130
+ ```bash
131
+ pnpm kahunas -- workout serve --program <program-uuid>
132
+ pnpm kahunas -- workout serve --workout <workout-uuid>
133
+ pnpm kahunas -- workout serve --limit 3
134
+ ```
135
+
136
+ Use `?day=<index>` to switch the selected workout day tab in the browser.
137
+
115
138
  ## Auto-login
116
139
 
117
140
  Most commands auto-login by default if a token is missing or expired. To disable:
package/dist/cli.js CHANGED
@@ -34,6 +34,7 @@ let node_path = require("node:path");
34
34
  node_path = __toESM(node_path);
35
35
  let node_readline = require("node:readline");
36
36
  node_readline = __toESM(node_readline);
37
+ let node_http = require("node:http");
37
38
 
38
39
  //#region src/args.ts
39
40
  function parseArgs(argv) {
@@ -532,7 +533,7 @@ async function loginAndPersist(options, config, outputMode) {
532
533
  //#endregion
533
534
  //#region src/usage.ts
534
535
  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] [--full] [--latest] [--limit N] [--debug-preview] [--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
+ console.log(`kahunas - CLI for Kahunas API\n\nUsage:\n kahunas auth set <token> [--base-url URL] [--csrf CSRF] [--web-base-url URL] [--cookie COOKIE] [--csrf-cookie VALUE]\n kahunas auth token [--csrf CSRF] [--cookie COOKIE] [--csrf-cookie VALUE] [--web-base-url URL] [--raw]\n kahunas auth login [--web-base-url URL] [--headless] [--raw]\n kahunas auth status [--token TOKEN] [--base-url URL] [--auto-login] [--headless]\n kahunas auth show\n kahunas checkins list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout list [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout pick [--page N] [--rpp N] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout latest [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n kahunas workout events [--user UUID] [--timezone TZ] [--program UUID] [--workout UUID] [--minimal] [--full] [--latest] [--limit N] [--debug-preview] [--raw] [--no-auto-login] [--headless]\n kahunas workout serve [--host HOST] [--port N] [--timezone TZ] [--program UUID] [--workout UUID] [--limit N] [--cache-ttl MS] [--no-auto-login] [--headless]\n kahunas workout sync [--headless]\n kahunas workout program <id> [--csrf CSRF] [--token TOKEN] [--base-url URL] [--raw] [--no-auto-login] [--headless]\n\nEnv:\n KAHUNAS_TOKEN=...\n KAHUNAS_CSRF=...\n KAHUNAS_CSRF_COOKIE=...\n KAHUNAS_COOKIE=...\n KAHUNAS_WEB_BASE_URL=...\n KAHUNAS_USER_UUID=...\n\nConfig:\n ${CONFIG_PATH}`);
536
537
  }
537
538
 
538
539
  //#endregion
@@ -688,6 +689,32 @@ async function handleCheckins(positionals, options) {
688
689
 
689
690
  //#endregion
690
691
  //#region src/events.ts
692
+ function summarizeWorkoutProgramDays(program) {
693
+ const candidates = extractProgramDayCandidates(program);
694
+ if (candidates.length === 0) return [];
695
+ const seen = /* @__PURE__ */ new Set();
696
+ const unique = [];
697
+ for (const candidate of candidates) {
698
+ const key = [
699
+ candidate.day_index ?? "none",
700
+ candidate.day_label ?? "unknown",
701
+ candidate.sections.length
702
+ ].join("|");
703
+ if (seen.has(key)) continue;
704
+ seen.add(key);
705
+ unique.push(candidate);
706
+ }
707
+ return unique.sort((a, b) => {
708
+ const aIndex = a.day_index;
709
+ const bIndex = b.day_index;
710
+ if (aIndex !== void 0 && bIndex !== void 0) return aIndex - bIndex;
711
+ if (aIndex !== void 0) return -1;
712
+ if (bIndex !== void 0) return 1;
713
+ const aLabel = a.day_label ?? "";
714
+ const bLabel = b.day_label ?? "";
715
+ return aLabel.localeCompare(bLabel);
716
+ });
717
+ }
691
718
  function filterWorkoutEvents(payload, programFilter, workoutFilter) {
692
719
  if (!Array.isArray(payload)) return [];
693
720
  return payload.filter((entry) => {
@@ -1437,6 +1464,355 @@ function formatPathKey(key) {
1437
1464
  return `\"${key.replace(/\"/g, "\\\"")}\"`;
1438
1465
  }
1439
1466
 
1467
+ //#endregion
1468
+ //#region src/server/workout-view.ts
1469
+ function renderWorkoutPage(options) {
1470
+ const { summary, days, selectedDayIndex, timezone, apiPath, refreshPath, isLatest } = options;
1471
+ const eventTitle = summary?.event.title ?? "Workout";
1472
+ const eventStart = summary?.event.start ?? "";
1473
+ const programTitle = summary?.program?.title ?? "Program";
1474
+ const selected = selectedDayIndex !== void 0 && days[selectedDayIndex] ? days[selectedDayIndex] : summary?.workout_day ?? null;
1475
+ const tabs = days.length > 0 ? `<nav class="tabs">${days.map((day, index) => {
1476
+ const label = escapeHtml(day.day_label ?? `Day ${index + 1}`);
1477
+ return `<a class="tab ${index === selectedDayIndex ? "active" : ""}" href="/?day=${index}">${label}</a>`;
1478
+ }).join("")}</nav>` : "";
1479
+ const totalVolumeSets = selected?.total_volume_sets ?? [];
1480
+ const totalVolumeSection = totalVolumeSets.length > 0 ? `
1481
+ <section class="section">
1482
+ <h2>Total Volume Sets</h2>
1483
+ <div class="chips">
1484
+ ${totalVolumeSets.map((entry) => `<span class="chip">${escapeHtml(entry.body_part)} ${formatNumber(entry.sets)}</span>`).join("")}
1485
+ </div>
1486
+ </section>
1487
+ ` : "";
1488
+ const sections = selected?.sections?.length ? selected.sections.map((section) => {
1489
+ const groups = section.groups.map((group, groupIndex) => {
1490
+ return `<div class="group">${group.type === "superset" ? `<div class="group-label">Superset</div>` : ""}${group.exercises.map((exercise, rowIndex) => renderExerciseRow(exercise, groupIndex + rowIndex)).join("")}</div>`;
1491
+ }).join("");
1492
+ return `
1493
+ <section class="section">
1494
+ <h2>${escapeHtml(section.label)}</h2>
1495
+ <div class="section-body">${groups}</div>
1496
+ </section>
1497
+ `;
1498
+ }).join("") : `<div class="empty">No workout data found for this day.</div>`;
1499
+ return `<!doctype html>
1500
+ <html lang="en">
1501
+ <head>
1502
+ <meta charset="utf-8" />
1503
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1504
+ <title>${escapeHtml(programTitle)} | Workout</title>
1505
+ <style>
1506
+ @import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Spline+Sans:wght@400;500;600&display=swap");
1507
+
1508
+ :root {
1509
+ --bg-1: #f6f7fb;
1510
+ --bg-2: #eef3f9;
1511
+ --card: #ffffff;
1512
+ --ink: #1f2430;
1513
+ --muted: #6b7280;
1514
+ --line: #e3e8f2;
1515
+ --accent: #2f7f6f;
1516
+ --accent-soft: #e7f5f1;
1517
+ --chip: #eaf5ff;
1518
+ --shadow: 0 12px 30px rgba(31, 36, 48, 0.08);
1519
+ }
1520
+
1521
+ * {
1522
+ box-sizing: border-box;
1523
+ }
1524
+
1525
+ body {
1526
+ margin: 0;
1527
+ font-family: "Spline Sans", sans-serif;
1528
+ color: var(--ink);
1529
+ background: linear-gradient(160deg, var(--bg-1), var(--bg-2));
1530
+ min-height: 100vh;
1531
+ }
1532
+
1533
+ body::before {
1534
+ content: "";
1535
+ position: fixed;
1536
+ inset: 0;
1537
+ background: radial-gradient(circle at top right, rgba(47, 127, 111, 0.12), transparent 45%);
1538
+ pointer-events: none;
1539
+ }
1540
+
1541
+ .page {
1542
+ max-width: 1120px;
1543
+ margin: 0 auto;
1544
+ padding: 40px 24px 72px;
1545
+ }
1546
+
1547
+ .hero {
1548
+ background: var(--card);
1549
+ border-radius: 28px;
1550
+ padding: 28px 32px;
1551
+ box-shadow: var(--shadow);
1552
+ display: grid;
1553
+ gap: 20px;
1554
+ }
1555
+
1556
+ .title {
1557
+ font-family: "Fraunces", serif;
1558
+ font-size: 32px;
1559
+ margin: 0;
1560
+ }
1561
+
1562
+ .subtitle {
1563
+ color: var(--muted);
1564
+ font-size: 14px;
1565
+ letter-spacing: 0.02em;
1566
+ }
1567
+
1568
+ .meta {
1569
+ display: flex;
1570
+ flex-wrap: wrap;
1571
+ gap: 12px;
1572
+ align-items: center;
1573
+ }
1574
+
1575
+ .meta-pill {
1576
+ background: var(--accent-soft);
1577
+ color: var(--accent);
1578
+ padding: 6px 12px;
1579
+ border-radius: 999px;
1580
+ font-weight: 600;
1581
+ font-size: 12px;
1582
+ }
1583
+
1584
+ .meta-pill.latest {
1585
+ background: #1f2a3d;
1586
+ color: #f5f7ff;
1587
+ }
1588
+
1589
+ .actions {
1590
+ display: flex;
1591
+ gap: 12px;
1592
+ flex-wrap: wrap;
1593
+ }
1594
+
1595
+ .button {
1596
+ display: inline-flex;
1597
+ align-items: center;
1598
+ gap: 8px;
1599
+ background: var(--card);
1600
+ border: 1px solid var(--line);
1601
+ border-radius: 999px;
1602
+ padding: 8px 16px;
1603
+ text-decoration: none;
1604
+ color: var(--ink);
1605
+ font-weight: 600;
1606
+ }
1607
+
1608
+ .tabs {
1609
+ display: flex;
1610
+ gap: 10px;
1611
+ flex-wrap: wrap;
1612
+ padding: 18px 0 4px;
1613
+ }
1614
+
1615
+ .tab {
1616
+ padding: 8px 14px;
1617
+ border-radius: 999px;
1618
+ border: 1px solid var(--line);
1619
+ text-decoration: none;
1620
+ color: var(--muted);
1621
+ font-weight: 600;
1622
+ background: #f9fbff;
1623
+ }
1624
+
1625
+ .tab.active {
1626
+ color: var(--accent);
1627
+ border-color: var(--accent);
1628
+ background: #f2fbf8;
1629
+ }
1630
+
1631
+ .section {
1632
+ margin-top: 28px;
1633
+ }
1634
+
1635
+ .section h2 {
1636
+ margin: 0 0 14px;
1637
+ font-size: 18px;
1638
+ font-weight: 700;
1639
+ }
1640
+
1641
+ .section-body {
1642
+ display: grid;
1643
+ gap: 14px;
1644
+ }
1645
+
1646
+ .group {
1647
+ display: grid;
1648
+ gap: 10px;
1649
+ }
1650
+
1651
+ .group-label {
1652
+ font-size: 12px;
1653
+ text-transform: uppercase;
1654
+ letter-spacing: 0.1em;
1655
+ color: var(--muted);
1656
+ padding-left: 6px;
1657
+ }
1658
+
1659
+ .exercise {
1660
+ background: var(--card);
1661
+ border-radius: 16px;
1662
+ border: 1px solid var(--line);
1663
+ padding: 16px 18px;
1664
+ display: grid;
1665
+ grid-template-columns: 56px 1fr auto;
1666
+ gap: 12px;
1667
+ align-items: center;
1668
+ box-shadow: 0 4px 16px rgba(31, 36, 48, 0.04);
1669
+ animation: fadeSlide 0.5s ease both;
1670
+ animation-delay: var(--delay, 0s);
1671
+ }
1672
+
1673
+ .badge {
1674
+ width: 46px;
1675
+ height: 46px;
1676
+ border-radius: 14px;
1677
+ background: var(--chip);
1678
+ display: flex;
1679
+ align-items: center;
1680
+ justify-content: center;
1681
+ font-weight: 700;
1682
+ color: #275b8b;
1683
+ }
1684
+
1685
+ .exercise h3 {
1686
+ margin: 0;
1687
+ font-size: 16px;
1688
+ font-weight: 700;
1689
+ }
1690
+
1691
+ .exercise p {
1692
+ margin: 4px 0 0;
1693
+ color: var(--muted);
1694
+ font-size: 13px;
1695
+ }
1696
+
1697
+ .metrics {
1698
+ display: flex;
1699
+ flex-direction: column;
1700
+ gap: 6px;
1701
+ font-size: 13px;
1702
+ color: var(--muted);
1703
+ min-width: 140px;
1704
+ text-align: right;
1705
+ }
1706
+
1707
+ .chips {
1708
+ display: flex;
1709
+ flex-wrap: wrap;
1710
+ gap: 8px;
1711
+ margin-top: 12px;
1712
+ }
1713
+
1714
+ .chip {
1715
+ padding: 6px 12px;
1716
+ border-radius: 999px;
1717
+ background: #e9f7f2;
1718
+ color: #2f7f6f;
1719
+ font-size: 12px;
1720
+ font-weight: 600;
1721
+ }
1722
+
1723
+ .chip.muted {
1724
+ background: #f1f3f7;
1725
+ color: var(--muted);
1726
+ }
1727
+
1728
+ .empty {
1729
+ margin-top: 20px;
1730
+ padding: 24px;
1731
+ border-radius: 16px;
1732
+ border: 1px dashed var(--line);
1733
+ color: var(--muted);
1734
+ background: rgba(255, 255, 255, 0.6);
1735
+ }
1736
+
1737
+ @keyframes fadeSlide {
1738
+ from { opacity: 0; transform: translateY(6px); }
1739
+ to { opacity: 1; transform: translateY(0); }
1740
+ }
1741
+
1742
+ @media (max-width: 720px) {
1743
+ .exercise {
1744
+ grid-template-columns: 48px 1fr;
1745
+ }
1746
+ .metrics {
1747
+ text-align: left;
1748
+ }
1749
+ }
1750
+ </style>
1751
+ </head>
1752
+ <body>
1753
+ <div class="page">
1754
+ <header class="hero">
1755
+ <div>
1756
+ <div class="subtitle">${escapeHtml(programTitle)}</div>
1757
+ <h1 class="title">${escapeHtml(eventTitle)}</h1>
1758
+ <div class="meta">
1759
+ ${eventStart ? `<span class="meta-pill">${escapeHtml(eventStart)}</span>` : ""}
1760
+ <span class="meta-pill">${escapeHtml(timezone)}</span>
1761
+ ${isLatest ? `<span class="meta-pill latest">Latest event</span>` : ""}
1762
+ </div>
1763
+ </div>
1764
+ <div class="actions">
1765
+ <a class="button" href="${escapeHtml(refreshPath)}">Refresh</a>
1766
+ <a class="button" href="${escapeHtml(apiPath)}" target="_blank">JSON</a>
1767
+ </div>
1768
+ ${tabs}
1769
+ </header>
1770
+
1771
+ ${totalVolumeSection}
1772
+ ${sections}
1773
+ </div>
1774
+ </body>
1775
+ </html>`;
1776
+ }
1777
+ function renderExerciseRow(exercise, index) {
1778
+ const badge = escapeHtml(exercise.sequence ?? String(index + 1));
1779
+ const metrics = [
1780
+ exercise.sets !== void 0 ? `Sets ${exercise.sets}` : null,
1781
+ exercise.reps ? `Reps ${escapeHtml(exercise.reps)}` : null,
1782
+ exercise.rest_seconds !== void 0 ? `Rest ${formatDuration(exercise.rest_seconds)}` : null,
1783
+ exercise.time_seconds !== void 0 ? `Time ${formatDuration(exercise.time_seconds)}` : null
1784
+ ].filter(Boolean).join(" | ");
1785
+ return `
1786
+ <div class="exercise" style="--delay: ${Math.min(index * .04, .4)}s">
1787
+ <div class="badge">${badge}</div>
1788
+ <div>
1789
+ <h3>${escapeHtml(exercise.name)}</h3>
1790
+ <p>${metrics || "No prescription"}</p>
1791
+ </div>
1792
+ <div class="metrics">
1793
+ ${exercise.body_parts?.length ? formatBodyParts(exercise.body_parts) : ""}
1794
+ </div>
1795
+ </div>
1796
+ `;
1797
+ }
1798
+ function formatBodyParts(parts) {
1799
+ return parts.slice(0, 3).map((part) => `${escapeHtml(part.name)}${part.volume !== void 0 ? ` ${formatNumber(part.volume)}` : ""}`).join("<br />");
1800
+ }
1801
+ function formatDuration(value) {
1802
+ const total = Math.max(0, Math.round(value));
1803
+ const minutes = Math.floor(total / 60);
1804
+ const seconds = total % 60;
1805
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
1806
+ return `${seconds}s`;
1807
+ }
1808
+ function formatNumber(value) {
1809
+ if (Number.isInteger(value)) return String(value);
1810
+ return value.toFixed(1).replace(/\.0$/, "");
1811
+ }
1812
+ function escapeHtml(value) {
1813
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;").replace(/'/g, "&#39;");
1814
+ }
1815
+
1440
1816
  //#endregion
1441
1817
  //#region src/commands/workout.ts
1442
1818
  async function handleWorkout(positionals, options) {
@@ -1453,6 +1829,14 @@ async function handleWorkout(positionals, options) {
1453
1829
  else throw new Error("Missing auth token. Set KAHUNAS_TOKEN or run 'kahunas auth login'.");
1454
1830
  return token;
1455
1831
  };
1832
+ let webLoginInFlight = null;
1833
+ const ensureWebLogin = async () => {
1834
+ if (!autoLogin) return;
1835
+ if (!webLoginInFlight) webLoginInFlight = loginAndPersist(options, config, "silent").then(() => void 0).finally(() => {
1836
+ webLoginInFlight = null;
1837
+ });
1838
+ await webLoginInFlight;
1839
+ };
1456
1840
  const baseUrl = resolveBaseUrl(options, config);
1457
1841
  const rawOutput = isFlagEnabled(options, "raw");
1458
1842
  const page = parseNumber$1(options.page, 1);
@@ -1477,67 +1861,7 @@ async function handleWorkout(positionals, options) {
1477
1861
  cache
1478
1862
  };
1479
1863
  };
1480
- if (action === "list") {
1481
- const { response, plans, cache } = await fetchList();
1482
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
1483
- if (rawOutput) {
1484
- printResponse(response, rawOutput);
1485
- return;
1486
- }
1487
- const output = {
1488
- source: cache ? "api+cache" : "api",
1489
- cache: cache ? {
1490
- updated_at: cache.updatedAt,
1491
- count: cache.plans.length,
1492
- path: WORKOUT_CACHE_PATH
1493
- } : void 0,
1494
- data: { workout_plan: plans }
1495
- };
1496
- console.log(JSON.stringify(output, null, 2));
1497
- return;
1498
- }
1499
- if (action === "pick") {
1500
- const { response, plans } = await fetchList();
1501
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
1502
- if (plans.length === 0) throw new Error("No workout programs found.");
1503
- if (!rawOutput) {
1504
- console.log("Pick a workout program:");
1505
- plans.forEach((plan, index) => {
1506
- console.log(`${index + 1}) ${formatWorkoutSummary(plan)}`);
1507
- });
1508
- }
1509
- const answer = await askQuestion(`Enter number (1-${plans.length}): `);
1510
- const selection = Number.parseInt(answer, 10);
1511
- if (Number.isNaN(selection) || selection < 1 || selection > plans.length) throw new Error("Invalid selection.");
1512
- const chosen = plans[selection - 1];
1513
- if (!chosen.uuid) throw new Error("Selected workout is missing a uuid.");
1514
- const csrfToken$1 = resolveCsrfToken(options, config);
1515
- let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
1516
- if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
1517
- token = await loginAndPersist(options, config, "silent");
1518
- responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
1519
- }
1520
- if (!responseProgram$1.ok) throw new Error(`HTTP ${responseProgram$1.status}: ${responseProgram$1.text}`);
1521
- printResponse(responseProgram$1, rawOutput);
1522
- return;
1523
- }
1524
- if (action === "latest") {
1525
- const { response, plans } = await fetchList();
1526
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
1527
- if (plans.length === 0) throw new Error("No workout programs found.");
1528
- const chosen = pickLatestWorkout(plans);
1529
- if (!chosen || !chosen.uuid) throw new Error("Latest workout is missing a uuid.");
1530
- const csrfToken$1 = resolveCsrfToken(options, config);
1531
- let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
1532
- if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
1533
- token = await loginAndPersist(options, config, "silent");
1534
- responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
1535
- }
1536
- if (!responseProgram$1.ok) throw new Error(`HTTP ${responseProgram$1.status}: ${responseProgram$1.text}`);
1537
- printResponse(responseProgram$1, rawOutput);
1538
- return;
1539
- }
1540
- if (action === "events") {
1864
+ const fetchWorkoutEventsPayload = async () => {
1541
1865
  const baseWebUrl = resolveWebBaseUrl(options, config);
1542
1866
  const webOrigin = new URL(baseWebUrl).origin;
1543
1867
  const timezone = options.timezone ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "Europe/London";
@@ -1571,17 +1895,13 @@ async function handleWorkout(positionals, options) {
1571
1895
  ...config,
1572
1896
  userUuid
1573
1897
  });
1574
- const minimal = isFlagEnabled(options, "minimal");
1575
- const full = isFlagEnabled(options, "full");
1576
- const debugPreview = isFlagEnabled(options, "debug-preview");
1577
- const limit = isFlagEnabled(options, "latest") || isFlagEnabled(options, "last") ? 1 : parseNumber$1(options.limit, 0);
1578
1898
  let csrfToken$1 = resolveCsrfToken(options, config);
1579
1899
  let csrfCookie = resolveCsrfCookie(options, config);
1580
1900
  let authCookie = resolveAuthCookie(options, config);
1581
1901
  let effectiveCsrfToken = csrfCookie ?? csrfToken$1;
1582
1902
  let cookieHeader = authCookie ?? (effectiveCsrfToken ? `csrf_kahunas_cookie_token=${effectiveCsrfToken}` : void 0);
1583
1903
  if ((!csrfToken$1 || !cookieHeader || !authCookie) && autoLogin) {
1584
- await loginAndPersist(options, config, "silent");
1904
+ await ensureWebLogin();
1585
1905
  const refreshed = readConfig();
1586
1906
  csrfToken$1 = resolveCsrfToken(options, refreshed);
1587
1907
  csrfCookie = resolveCsrfCookie(options, refreshed);
@@ -1611,7 +1931,7 @@ async function handleWorkout(positionals, options) {
1611
1931
  let text = await response.text();
1612
1932
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`);
1613
1933
  if (autoLogin && isLikelyLoginHtml(text)) {
1614
- await loginAndPersist(options, config, "silent");
1934
+ await ensureWebLogin();
1615
1935
  const refreshed = readConfig();
1616
1936
  csrfToken$1 = resolveCsrfToken(options, refreshed);
1617
1937
  csrfCookie = resolveCsrfCookie(options, refreshed);
@@ -1634,21 +1954,14 @@ async function handleWorkout(positionals, options) {
1634
1954
  text = await retry.text();
1635
1955
  if (!retry.ok) throw new Error(`HTTP ${retry.status}: ${text}`);
1636
1956
  }
1637
- if (rawOutput) {
1638
- console.log(text);
1639
- return;
1640
- }
1641
1957
  const payload = parseJsonText(text);
1642
- if (!Array.isArray(payload)) {
1643
- console.log(text);
1644
- return;
1645
- }
1646
- const sorted = sortWorkoutEvents(filterWorkoutEvents(payload, options.program, options.workout));
1647
- const limited = limit > 0 ? sorted.slice(-limit) : sorted;
1648
- if (minimal) {
1649
- console.log(JSON.stringify(limited, null, 2));
1650
- return;
1651
- }
1958
+ return {
1959
+ text,
1960
+ payload,
1961
+ timezone
1962
+ };
1963
+ };
1964
+ const buildProgramDetails = async (events) => {
1652
1965
  let programIndex;
1653
1966
  let plans = readWorkoutCache()?.plans ?? [];
1654
1967
  try {
@@ -1664,8 +1977,11 @@ async function handleWorkout(positionals, options) {
1664
1977
  if (listResponse.ok) plans = mergeWorkoutPlans(extractWorkoutPlans(listResponse.json), plans);
1665
1978
  } catch {}
1666
1979
  if (plans.length > 0) programIndex = buildWorkoutPlanIndex(plans);
1980
+ const refreshed = readConfig();
1981
+ const csrfToken$1 = resolveCsrfToken(options, refreshed);
1982
+ const effectiveCsrfToken = resolveCsrfCookie(options, refreshed) ?? csrfToken$1;
1667
1983
  const programDetails = {};
1668
- const programIds = Array.from(new Set(sorted.map((entry) => {
1984
+ const programIds = Array.from(new Set(events.map((entry) => {
1669
1985
  if (!entry || typeof entry !== "object") return;
1670
1986
  const record = entry;
1671
1987
  return typeof record.program === "string" ? record.program : void 0;
@@ -1694,6 +2010,109 @@ async function handleWorkout(positionals, options) {
1694
2010
  } catch {}
1695
2011
  programDetails[programId$1] = programIndex?.[programId$1] ?? null;
1696
2012
  }
2013
+ return programDetails;
2014
+ };
2015
+ const resolveSelectedDayIndex = (days, eventDayIndex, eventDayLabel, dayParam) => {
2016
+ const parseOptionalInt = (value) => {
2017
+ if (!value) return;
2018
+ const parsed = Number.parseInt(value, 10);
2019
+ return Number.isFinite(parsed) ? parsed : void 0;
2020
+ };
2021
+ const normalize = (value) => value.trim().toLowerCase();
2022
+ const paramIndex = parseOptionalInt(dayParam);
2023
+ if (paramIndex !== void 0 && days[paramIndex]) return paramIndex;
2024
+ if (eventDayIndex !== void 0) {
2025
+ const matchIndex = days.findIndex((day) => day.day_index === eventDayIndex);
2026
+ if (matchIndex >= 0) return matchIndex;
2027
+ }
2028
+ if (eventDayLabel) {
2029
+ const normalized = normalize(eventDayLabel);
2030
+ const matchIndex = days.findIndex((day) => day.day_label ? normalize(day.day_label).includes(normalized) : false);
2031
+ if (matchIndex >= 0) return matchIndex;
2032
+ }
2033
+ if (days.length > 0) return 0;
2034
+ };
2035
+ if (action === "list") {
2036
+ const { response, plans, cache } = await fetchList();
2037
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
2038
+ if (rawOutput) {
2039
+ printResponse(response, rawOutput);
2040
+ return;
2041
+ }
2042
+ const output = {
2043
+ source: cache ? "api+cache" : "api",
2044
+ cache: cache ? {
2045
+ updated_at: cache.updatedAt,
2046
+ count: cache.plans.length,
2047
+ path: WORKOUT_CACHE_PATH
2048
+ } : void 0,
2049
+ data: { workout_plan: plans }
2050
+ };
2051
+ console.log(JSON.stringify(output, null, 2));
2052
+ return;
2053
+ }
2054
+ if (action === "pick") {
2055
+ const { response, plans } = await fetchList();
2056
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
2057
+ if (plans.length === 0) throw new Error("No workout programs found.");
2058
+ if (!rawOutput) {
2059
+ console.log("Pick a workout program:");
2060
+ plans.forEach((plan, index) => {
2061
+ console.log(`${index + 1}) ${formatWorkoutSummary(plan)}`);
2062
+ });
2063
+ }
2064
+ const answer = await askQuestion(`Enter number (1-${plans.length}): `);
2065
+ const selection = Number.parseInt(answer, 10);
2066
+ if (Number.isNaN(selection) || selection < 1 || selection > plans.length) throw new Error("Invalid selection.");
2067
+ const chosen = plans[selection - 1];
2068
+ if (!chosen.uuid) throw new Error("Selected workout is missing a uuid.");
2069
+ const csrfToken$1 = resolveCsrfToken(options, config);
2070
+ let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
2071
+ if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
2072
+ token = await loginAndPersist(options, config, "silent");
2073
+ responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
2074
+ }
2075
+ if (!responseProgram$1.ok) throw new Error(`HTTP ${responseProgram$1.status}: ${responseProgram$1.text}`);
2076
+ printResponse(responseProgram$1, rawOutput);
2077
+ return;
2078
+ }
2079
+ if (action === "latest") {
2080
+ const { response, plans } = await fetchList();
2081
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.text}`);
2082
+ if (plans.length === 0) throw new Error("No workout programs found.");
2083
+ const chosen = pickLatestWorkout(plans);
2084
+ if (!chosen || !chosen.uuid) throw new Error("Latest workout is missing a uuid.");
2085
+ const csrfToken$1 = resolveCsrfToken(options, config);
2086
+ let responseProgram$1 = await fetchWorkoutProgram(await ensureToken(), baseUrl, chosen.uuid, csrfToken$1);
2087
+ if (autoLogin && isTokenExpiredResponse(responseProgram$1.json)) {
2088
+ token = await loginAndPersist(options, config, "silent");
2089
+ responseProgram$1 = await fetchWorkoutProgram(token, baseUrl, chosen.uuid, csrfToken$1);
2090
+ }
2091
+ if (!responseProgram$1.ok) throw new Error(`HTTP ${responseProgram$1.status}: ${responseProgram$1.text}`);
2092
+ printResponse(responseProgram$1, rawOutput);
2093
+ return;
2094
+ }
2095
+ if (action === "events") {
2096
+ const minimal = isFlagEnabled(options, "minimal");
2097
+ const full = isFlagEnabled(options, "full");
2098
+ const debugPreview = isFlagEnabled(options, "debug-preview");
2099
+ const limit = isFlagEnabled(options, "latest") || isFlagEnabled(options, "last") ? 1 : parseNumber$1(options.limit, 0);
2100
+ const { text, payload, timezone } = await fetchWorkoutEventsPayload();
2101
+ if (rawOutput) {
2102
+ console.log(text);
2103
+ return;
2104
+ }
2105
+ if (!Array.isArray(payload)) {
2106
+ console.log(text);
2107
+ return;
2108
+ }
2109
+ const sorted = sortWorkoutEvents(filterWorkoutEvents(payload, options.program, options.workout));
2110
+ const limited = limit > 0 ? sorted.slice(-limit) : sorted;
2111
+ if (minimal) {
2112
+ console.log(JSON.stringify(limited, null, 2));
2113
+ return;
2114
+ }
2115
+ const programDetails = await buildProgramDetails(sorted);
1697
2116
  if (debugPreview) for (const entry of limited) {
1698
2117
  const record = entry;
1699
2118
  const eventId = typeof record.id === "string" || typeof record.id === "number" ? record.id : "unknown";
@@ -1717,6 +2136,96 @@ async function handleWorkout(positionals, options) {
1717
2136
  console.log(JSON.stringify(formatted, null, 2));
1718
2137
  return;
1719
2138
  }
2139
+ if (action === "serve") {
2140
+ const host = options.host ?? "127.0.0.1";
2141
+ const port = parseNumber$1(options.port, 3e3);
2142
+ const limit = parseNumber$1(options.limit, 1);
2143
+ const cacheTtlMs = parseNumber$1(options["cache-ttl"], 3e4);
2144
+ const loadSummary = async () => {
2145
+ const { text, payload, timezone } = await fetchWorkoutEventsPayload();
2146
+ if (!Array.isArray(payload)) throw new Error(`Unexpected calendar response: ${text.slice(0, 200)}`);
2147
+ const sorted = sortWorkoutEvents(filterWorkoutEvents(payload, options.program, options.workout));
2148
+ const bounded = limit > 0 ? sorted.slice(-limit) : sorted;
2149
+ const programDetails = await buildProgramDetails(sorted);
2150
+ const formatted = formatWorkoutEventsOutput(bounded, programDetails, {
2151
+ timezone,
2152
+ program: options.program,
2153
+ workout: options.workout
2154
+ });
2155
+ const summary = formatted.events[0];
2156
+ const programUuid = summary?.program?.uuid ?? (bounded[0] && typeof bounded[0] === "object" ? bounded[0].program : void 0);
2157
+ return {
2158
+ formatted,
2159
+ days: summarizeWorkoutProgramDays(programUuid ? programDetails[programUuid] : void 0),
2160
+ summary,
2161
+ timezone
2162
+ };
2163
+ };
2164
+ let cached;
2165
+ let summaryInFlight = null;
2166
+ const getSummary = async (forceRefresh) => {
2167
+ if (!forceRefresh && cached && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.data;
2168
+ if (!forceRefresh && summaryInFlight) return summaryInFlight;
2169
+ const pending = loadSummary().finally(() => {
2170
+ summaryInFlight = null;
2171
+ });
2172
+ summaryInFlight = pending;
2173
+ const data = await pending;
2174
+ cached = {
2175
+ data,
2176
+ fetchedAt: Date.now()
2177
+ };
2178
+ return data;
2179
+ };
2180
+ (0, node_http.createServer)(async (req, res) => {
2181
+ try {
2182
+ const url = new URL(req.url ?? "/", `http://${host}:${port}`);
2183
+ const wantsRefresh = url.searchParams.get("refresh") === "1";
2184
+ if (url.pathname === "/api/workout") {
2185
+ const data$1 = await getSummary(wantsRefresh);
2186
+ res.statusCode = 200;
2187
+ res.setHeader("content-type", "application/json; charset=utf-8");
2188
+ res.setHeader("cache-control", "no-store");
2189
+ res.end(JSON.stringify(data$1.formatted, null, 2));
2190
+ return;
2191
+ }
2192
+ if (url.pathname === "/favicon.ico") {
2193
+ res.statusCode = 204;
2194
+ res.end();
2195
+ return;
2196
+ }
2197
+ if (url.pathname !== "/") {
2198
+ res.statusCode = 404;
2199
+ res.end("Not found");
2200
+ return;
2201
+ }
2202
+ const data = await getSummary(wantsRefresh);
2203
+ const dayParam = url.searchParams.get("day");
2204
+ const selectedDayIndex = resolveSelectedDayIndex(data.days, data.summary?.workout_day?.day_index, data.summary?.workout_day?.day_label, dayParam);
2205
+ const html = renderWorkoutPage({
2206
+ summary: data.summary,
2207
+ days: data.days,
2208
+ selectedDayIndex,
2209
+ timezone: data.timezone,
2210
+ apiPath: "/api/workout",
2211
+ refreshPath: "/?refresh=1",
2212
+ isLatest: limit === 1
2213
+ });
2214
+ res.statusCode = 200;
2215
+ res.setHeader("content-type", "text/html; charset=utf-8");
2216
+ res.setHeader("cache-control", "no-store");
2217
+ res.end(html);
2218
+ } catch (error) {
2219
+ res.statusCode = 500;
2220
+ res.setHeader("content-type", "text/plain; charset=utf-8");
2221
+ res.end(error instanceof Error ? error.message : "Server error");
2222
+ }
2223
+ }).listen(port, host, () => {
2224
+ console.log(`Local workout server running at http://${host}:${port}`);
2225
+ console.log(`JSON endpoint at http://${host}:${port}/api/workout`);
2226
+ });
2227
+ return;
2228
+ }
1720
2229
  if (action === "sync") {
1721
2230
  const captured = await captureWorkoutsFromBrowser(options, config);
1722
2231
  const nextConfig = { ...config };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kahunas-cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^22.10.5",
26
+ "lefthook": "^2.0.15",
26
27
  "rolldown": "1.0.0-beta.59",
27
28
  "typescript": "^5.7.3",
28
29
  "vitest": "^1.6.0"