runspector 0.5.0__py3-none-any.whl
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.
- runspector/__init__.py +3 -0
- runspector/auth.py +291 -0
- runspector/cli.py +387 -0
- runspector/demo.py +154 -0
- runspector/facts.py +278 -0
- runspector/garmin.py +924 -0
- runspector/habits.py +311 -0
- runspector/logic.py +591 -0
- runspector/mcp_server.py +351 -0
- runspector/migrations/0001_init.sql +99 -0
- runspector/migrations/0002_workouts.sql +13 -0
- runspector/migrations/0003_proposal_items.sql +31 -0
- runspector/migrations/0004_load_balance.sql +19 -0
- runspector/models.py +170 -0
- runspector/onboarding.py +207 -0
- runspector/paths.py +359 -0
- runspector/plan.py +1172 -0
- runspector/planning.py +953 -0
- runspector/profile.py +117 -0
- runspector/skills/coach.md +128 -0
- runspector/skills/zones.md +122 -0
- runspector/snapshot.py +675 -0
- runspector/store.py +967 -0
- runspector/sync.py +286 -0
- runspector/templates.json +51 -0
- runspector/tools.py +756 -0
- runspector/web/__init__.py +0 -0
- runspector/web/agent.py +471 -0
- runspector/web/jobs.py +259 -0
- runspector/web/server.py +1146 -0
- runspector/web/static/app.js +1696 -0
- runspector/web/static/cards.css +126 -0
- runspector/web/static/cards.js +825 -0
- runspector/web/static/chassis.js +308 -0
- runspector/web/static/icon.svg +1 -0
- runspector/web/static/index.html +238 -0
- runspector/web/static/input-guard.js +36 -0
- runspector/web/static/logic.js +476 -0
- runspector/web/static/signin.js +49 -0
- runspector/web/static/theme-boot.js +14 -0
- runspector/web/static/theme.js +85 -0
- runspector/web/static/tokens.css +504 -0
- runspector/web/static/ui.js +478 -0
- runspector/web/static/you.js +297 -0
- runspector-0.5.0.dist-info/METADATA +83 -0
- runspector-0.5.0.dist-info/RECORD +49 -0
- runspector-0.5.0.dist-info/WHEEL +4 -0
- runspector-0.5.0.dist-info/entry_points.txt +2 -0
- runspector-0.5.0.dist-info/licenses/LICENSE +21 -0
runspector/__init__.py
ADDED
runspector/auth.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""One-time Garmin login. Asks for e-mail, password and (if the account needs
|
|
2
|
+
it) the MFA code - in the terminal (`runspector login`) or in the web page's
|
|
3
|
+
sign-in dialog (`WebLogin`) - then stores only the resulting session tokens.
|
|
4
|
+
Neither the password nor the code is ever written to disk."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import getpass
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import textwrap
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from . import paths
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _dump(client, store: str) -> None:
|
|
19
|
+
for attr in ("garth", "client"):
|
|
20
|
+
obj = getattr(client, attr, None)
|
|
21
|
+
if obj is not None and hasattr(obj, "dump"):
|
|
22
|
+
obj.dump(store)
|
|
23
|
+
return
|
|
24
|
+
raise RuntimeError("no dump()-capable token object found on the Garmin client")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verify() -> int:
|
|
28
|
+
"""Test the token resume only — no credentials involved."""
|
|
29
|
+
from .garmin import login
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
client = login()
|
|
33
|
+
print(f"OK - session resumed from {paths.garmin_dir()} (signed in as {client.get_full_name()})")
|
|
34
|
+
return 0
|
|
35
|
+
except Exception as exc: # noqa: BLE001 — any failure means "log in again"
|
|
36
|
+
print(f"Resume failed: {type(exc).__name__}: {exc}\nRun `runspector login`.", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: Said BEFORE the e-mail is asked for: what happens to it, what is kept, where,
|
|
41
|
+
#: and how to take it back. A login prompt without it asks for trust it has not
|
|
42
|
+
#: explained. One list for both ways in: the terminal prints it, the web page's
|
|
43
|
+
#: sign-in dialog shows the same points (`/api/state` → `garmin_login`).
|
|
44
|
+
NOTICE_POINTS = (
|
|
45
|
+
"Your e-mail and password go to Garmin only, once, to sign in. runspector never stores them.",
|
|
46
|
+
"Garmin answers with a session token. Only that token is saved, on this computer, in "
|
|
47
|
+
"{store} - a folder only your user account can read.",
|
|
48
|
+
"The token is sent to Garmin and nowhere else: runspector uses it to read your data and, "
|
|
49
|
+
"only after your yes, to put a workout on your watch.",
|
|
50
|
+
"To revoke it: delete that folder, or change your Garmin password.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
LOGIN_NOTICE = "How the Garmin sign-in works\n" + "".join(
|
|
54
|
+
textwrap.fill(p, 80, initial_indent=" - ", subsequent_indent=" ") + "\n"
|
|
55
|
+
for p in NOTICE_POINTS)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def notice_points(store) -> list[str]:
|
|
59
|
+
return [p.format(store=store) for p in NOTICE_POINTS]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _lock_down(store) -> None:
|
|
63
|
+
"""The token folder and its files readable by this user only. The home
|
|
64
|
+
directory is created 0700, but `RUNSPECTOR_GARMIN_TOKENS` can point anywhere,
|
|
65
|
+
and the notice above promises it for whatever folder is used. Windows has
|
|
66
|
+
no POSIX modes; there the user profile's ACL is what protects it."""
|
|
67
|
+
if os.name == "nt":
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
store.chmod(0o700)
|
|
71
|
+
for f in store.iterdir():
|
|
72
|
+
if f.is_file():
|
|
73
|
+
f.chmod(0o600)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _token_dir():
|
|
79
|
+
"""The token folder, created and locked down - or `None` and the sentence
|
|
80
|
+
that says why it cannot be used."""
|
|
81
|
+
store = paths.garmin_dir()
|
|
82
|
+
try:
|
|
83
|
+
store.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
# `RUNSPECTOR_GARMIN_TOKENS` pointing at a FILE is the likely mistake here
|
|
86
|
+
# — garth writes token FILES, so naming one is a natural misreading —
|
|
87
|
+
# and `mkdir` answered it with a raw FileExistsError traceback. Same
|
|
88
|
+
# class as the no-terminal login: say which path and what it should be.
|
|
89
|
+
return None, (f"Cannot use {store} as the token directory: {type(exc).__name__}: {exc}\n"
|
|
90
|
+
"RUNSPECTOR_GARMIN_TOKENS must name a DIRECTORY (garth writes several "
|
|
91
|
+
"token files into it), not a single file.")
|
|
92
|
+
_lock_down(store)
|
|
93
|
+
return store, ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def interactive_login(next_step: str | None = "`runspector serve`") -> int:
|
|
97
|
+
from garminconnect import Garmin
|
|
98
|
+
|
|
99
|
+
store, problem = _token_dir()
|
|
100
|
+
if store is None:
|
|
101
|
+
print(problem, file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
print(LOGIN_NOTICE.format(store=store))
|
|
104
|
+
# `input()` raises EOFError when there is no terminal - a pipe, a cron, a
|
|
105
|
+
# CI job, `< /dev/null` - and KeyboardInterrupt on Ctrl+C. Both used to end
|
|
106
|
+
# in a traceback, which is the wrong answer to "I ran this in the wrong
|
|
107
|
+
# place": say what the command needs instead.
|
|
108
|
+
try:
|
|
109
|
+
email = input("Garmin e-mail: ").strip()
|
|
110
|
+
password = getpass.getpass("Garmin password (not stored): ")
|
|
111
|
+
except EOFError:
|
|
112
|
+
print("\n`runspector login` is interactive: run it in a terminal where it can ask "
|
|
113
|
+
"for e-mail, password and the MFA code.", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
except KeyboardInterrupt:
|
|
116
|
+
print("\nLogin cancelled.", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
if not email or not password:
|
|
119
|
+
print("E-mail and password are required.", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
client = Garmin(email=email, password=password,
|
|
123
|
+
prompt_mfa=lambda: input("MFA code (e-mail/SMS/app): ").strip())
|
|
124
|
+
try:
|
|
125
|
+
client.login()
|
|
126
|
+
except (EOFError, KeyboardInterrupt):
|
|
127
|
+
print("\nLogin cancelled before the MFA code was entered.", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
except Exception as exc: # noqa: BLE001 — garminconnect raises many shapes
|
|
130
|
+
print(f"Login failed: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
131
|
+
return 2
|
|
132
|
+
|
|
133
|
+
_dump(client, str(store))
|
|
134
|
+
_lock_down(store)
|
|
135
|
+
try:
|
|
136
|
+
name = client.get_full_name()
|
|
137
|
+
except Exception: # noqa: BLE001 — a nicety; never worth failing the login over
|
|
138
|
+
name = "?"
|
|
139
|
+
print(f"OK - only the session token was saved, in {store} (signed in as {name})."
|
|
140
|
+
+ (f" Next: {next_step}" if next_step else ""))
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
#: How long a web sign-in waits for its MFA code. Garmin's e-mail with the code
|
|
145
|
+
#: can take a minute or two; the pending sign-in holds Garmin's session cookies
|
|
146
|
+
#: in memory, so it does not wait for ever.
|
|
147
|
+
MFA_WAIT_S = 600
|
|
148
|
+
|
|
149
|
+
#: Codes one sign-in takes before it ends. Whether Garmin took a code before
|
|
150
|
+
#: something failed cannot be read reliably from the library (its token
|
|
151
|
+
#: exchange runs inside the code step), so a failed code is never assumed to
|
|
152
|
+
#: be retryable for ever: after this many, only a new start - and a new code -
|
|
153
|
+
#: helps, and Garmin's MFA endpoint is not hammered with a spent one.
|
|
154
|
+
MFA_TRIES = 3
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _why(exc: Exception, *, mfa: bool = False) -> str:
|
|
158
|
+
"""The athlete's sentence for a failed sign-in. The class name decides, never
|
|
159
|
+
the message: garminconnect's texts are written for developers, and the page
|
|
160
|
+
shows this one to the athlete."""
|
|
161
|
+
name = type(exc).__name__
|
|
162
|
+
if "TooManyRequests" in name:
|
|
163
|
+
return ("Garmin is refusing sign-ins for now (too many attempts). Wait a few "
|
|
164
|
+
"minutes, then try again.")
|
|
165
|
+
if "Authentication" in name:
|
|
166
|
+
# The library raises this for a wrong code AND for one it could not
|
|
167
|
+
# get checked (offline, a Cloudflare page): say no more than that.
|
|
168
|
+
return ("Garmin did not accept that code, or could not check it. Check it and "
|
|
169
|
+
"try again - or start again for a new code." if mfa
|
|
170
|
+
else "Garmin did not accept that e-mail and password.")
|
|
171
|
+
return (f"The sign-in did not go through ({name}). Try again in a moment - "
|
|
172
|
+
f"`runspector login` in a terminal says more.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class WebLogin:
|
|
176
|
+
"""`runspector login` for the web page, in two calls: `start(email, password)`
|
|
177
|
+
and, when Garmin asks for a code, `finish(code)`.
|
|
178
|
+
|
|
179
|
+
Nothing is written until Garmin says yes, and then only the session token,
|
|
180
|
+
exactly as in the terminal. The password lives for the length of `start`;
|
|
181
|
+
a pending MFA sign-in (Garmin's session cookies, as sensitive as the
|
|
182
|
+
password) lives in this object's memory only and is never part of any
|
|
183
|
+
answer; past `MFA_WAIT_S` it is refused and dropped on the next call - the
|
|
184
|
+
page asks on every load.
|
|
185
|
+
|
|
186
|
+
One sign-in at a time, and a request while one runs is refused, not
|
|
187
|
+
queued: queued, a page that gave up on a slow Garmin and asked again sent
|
|
188
|
+
a second round of logins - and a second code - to the same account."""
|
|
189
|
+
|
|
190
|
+
_BUSY = {"error": "A sign-in is already under way - wait for its answer.", "busy": True}
|
|
191
|
+
|
|
192
|
+
def __init__(self, clock=time.monotonic):
|
|
193
|
+
self._lock = threading.Lock()
|
|
194
|
+
self._pending: tuple | None = None # (client, state, since)
|
|
195
|
+
self._tries = 0
|
|
196
|
+
#: Sign-ins that went through. A page following a late answer compares
|
|
197
|
+
#: it: "a session exists" is no proof, the expired one is still on disk.
|
|
198
|
+
self.signins = 0
|
|
199
|
+
self._clock = clock
|
|
200
|
+
|
|
201
|
+
def waiting_for_code(self) -> bool:
|
|
202
|
+
p = self._pending
|
|
203
|
+
if p is not None and self._clock() - p[2] > MFA_WAIT_S:
|
|
204
|
+
# Expired means gone, not only reported as gone: it holds
|
|
205
|
+
# Garmin's session cookies. The page asks on every load.
|
|
206
|
+
self._pending = p = None
|
|
207
|
+
return p is not None
|
|
208
|
+
|
|
209
|
+
def busy(self) -> bool:
|
|
210
|
+
"""A sign-in is talking to Garmin right now - the page that stopped
|
|
211
|
+
waiting for its answer follows it through `/api/state`."""
|
|
212
|
+
return self._lock.locked()
|
|
213
|
+
|
|
214
|
+
def cancel(self) -> None:
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._pending = None
|
|
217
|
+
|
|
218
|
+
def start(self, email: str, password: str) -> dict:
|
|
219
|
+
if not self._lock.acquire(blocking=False):
|
|
220
|
+
return dict(self._BUSY)
|
|
221
|
+
try:
|
|
222
|
+
return self._start(email, password)
|
|
223
|
+
finally:
|
|
224
|
+
self._lock.release()
|
|
225
|
+
|
|
226
|
+
def finish(self, code: str) -> dict:
|
|
227
|
+
if not self._lock.acquire(blocking=False):
|
|
228
|
+
return dict(self._BUSY)
|
|
229
|
+
try:
|
|
230
|
+
return self._finish(code)
|
|
231
|
+
finally:
|
|
232
|
+
self._lock.release()
|
|
233
|
+
|
|
234
|
+
def _start(self, email: str, password: str) -> dict:
|
|
235
|
+
from garminconnect import Garmin
|
|
236
|
+
|
|
237
|
+
self._pending = None
|
|
238
|
+
store, problem = _token_dir()
|
|
239
|
+
if store is None:
|
|
240
|
+
return {"error": problem}
|
|
241
|
+
client = Garmin(email=email, password=password, return_on_mfa=True)
|
|
242
|
+
try:
|
|
243
|
+
status, state = client.login()
|
|
244
|
+
except Exception as exc: # noqa: BLE001 — garminconnect raises many shapes
|
|
245
|
+
return {"error": _why(exc)}
|
|
246
|
+
finally:
|
|
247
|
+
# The MFA step does not need it, and a pending sign-in should not
|
|
248
|
+
# keep the password in memory for ten minutes.
|
|
249
|
+
client.password = None
|
|
250
|
+
if status == "needs_mfa":
|
|
251
|
+
self._pending, self._tries = (client, state, self._clock()), 0
|
|
252
|
+
return {"state": "code"}
|
|
253
|
+
return self._keep(client, store)
|
|
254
|
+
|
|
255
|
+
def _finish(self, code: str) -> dict:
|
|
256
|
+
p = self._pending
|
|
257
|
+
if p is None or self._clock() - p[2] > MFA_WAIT_S:
|
|
258
|
+
self._pending = None
|
|
259
|
+
return {"error": "This sign-in has expired - start again with your e-mail "
|
|
260
|
+
"and password.", "restart": True}
|
|
261
|
+
client, state, _ = p
|
|
262
|
+
try:
|
|
263
|
+
client.resume_login(state, code)
|
|
264
|
+
except Exception as exc: # noqa: BLE001
|
|
265
|
+
# A token the client already holds IS the sign-in: Garmin took the
|
|
266
|
+
# code and only the library's own profile load after it failed.
|
|
267
|
+
if not getattr(getattr(client, "client", None), "is_authenticated", False):
|
|
268
|
+
# Otherwise nothing tells a wrong code from one Garmin took
|
|
269
|
+
# before a later step failed: offer both ways out, and end
|
|
270
|
+
# the sign-in after `MFA_TRIES`.
|
|
271
|
+
self._tries += 1
|
|
272
|
+
if self._tries >= MFA_TRIES:
|
|
273
|
+
self._pending = None
|
|
274
|
+
return {"error": f"That was {MFA_TRIES} codes. Start again with your "
|
|
275
|
+
f"e-mail and password for a new one.", "restart": True}
|
|
276
|
+
return {"error": _why(exc, mfa=True)}
|
|
277
|
+
self._pending = None
|
|
278
|
+
store, problem = _token_dir()
|
|
279
|
+
if store is None:
|
|
280
|
+
return {"error": problem}
|
|
281
|
+
return self._keep(client, store)
|
|
282
|
+
|
|
283
|
+
def _keep(self, client, store) -> dict:
|
|
284
|
+
try:
|
|
285
|
+
_dump(client, str(store))
|
|
286
|
+
except Exception as exc: # noqa: BLE001
|
|
287
|
+
return {"error": f"Garmin signed you in, but the token could not be saved in "
|
|
288
|
+
f"{store} ({type(exc).__name__})."}
|
|
289
|
+
_lock_down(store)
|
|
290
|
+
self.signins += 1
|
|
291
|
+
return {"state": "signed_in"}
|
runspector/cli.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""`runspector` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import __version__, paths, planning, snapshot
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _utf8() -> None:
|
|
17
|
+
"""UTF-8 and LINE buffering. Without `line_buffering` nothing appears until the
|
|
18
|
+
process exits once stdout is a file rather than a terminal — so `runspector serve`
|
|
19
|
+
under a service manager, nohup or Task Scheduler wrote a zero-byte log and gave
|
|
20
|
+
no way to tell whether it had even come up."""
|
|
21
|
+
for stream in (sys.stdout, sys.stderr):
|
|
22
|
+
try:
|
|
23
|
+
stream.reconfigure(encoding="utf-8", errors="replace", line_buffering=True)
|
|
24
|
+
except (AttributeError, ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_login(args) -> int:
|
|
29
|
+
from . import auth
|
|
30
|
+
|
|
31
|
+
return auth.verify() if args.verify else auth.interactive_login()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_sync(args) -> int:
|
|
35
|
+
"""Exit 0 fine, 2 Garmin stopped us (expired session, rate limit, no route),
|
|
36
|
+
3 partial. Telling those apart matters: every failure used to be reported as a
|
|
37
|
+
login failure, which sends the athlete into an interactive password-and-MFA
|
|
38
|
+
re-login that cannot fix a rate limit."""
|
|
39
|
+
from . import garmin, sync
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
return sync.sync_now(args.days).exit_code
|
|
43
|
+
except Exception as exc: # noqa: BLE001 — a CLI prints a cause, it does not traceback
|
|
44
|
+
name = type(exc).__name__
|
|
45
|
+
if "Auth" in name or "login" in name.lower():
|
|
46
|
+
hint = "The stored Garmin session is no longer valid. Run `runspector login`."
|
|
47
|
+
elif garmin.is_fatal(exc):
|
|
48
|
+
hint = ("Garmin refused the request (rate limit or connection). Nothing was lost - "
|
|
49
|
+
"the sync is idempotent, so just run it again later.")
|
|
50
|
+
else:
|
|
51
|
+
hint = "Re-run with RUNSPECTOR_LOG=INFO for the full context."
|
|
52
|
+
print(f"Sync failed: {name}: {exc}\n{hint}", file=sys.stderr)
|
|
53
|
+
return 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_serve(args) -> int:
|
|
57
|
+
from .web import server
|
|
58
|
+
|
|
59
|
+
return server.serve(host=args.host, port=args.port, demo=args.demo,
|
|
60
|
+
open_browser=not args.no_browser, sync_on_start=not args.no_sync,
|
|
61
|
+
setup_check=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_mcp(_args) -> int:
|
|
65
|
+
from . import mcp_server
|
|
66
|
+
|
|
67
|
+
mcp_server.main()
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_profile(args) -> int:
|
|
72
|
+
from . import profile as profile_mod
|
|
73
|
+
|
|
74
|
+
current = profile_mod.load()
|
|
75
|
+
for key in ("max_hr", "aerobic_ref_hr", "goal"):
|
|
76
|
+
value = getattr(args, key)
|
|
77
|
+
if value is not None:
|
|
78
|
+
current[key] = value
|
|
79
|
+
# The week keys go through the same check as the You tab: a long run on a
|
|
80
|
+
# day off would otherwise reach the planner and fail every proposal.
|
|
81
|
+
week = {k: getattr(args, k) for k in ("days_per_week", "long_run_day") if getattr(args, k) is not None}
|
|
82
|
+
try:
|
|
83
|
+
profile = profile_mod.validate(week, current) if week else current
|
|
84
|
+
except ValueError as exc:
|
|
85
|
+
print(f"Not saved: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
profile_mod.write(profile)
|
|
88
|
+
print(json.dumps(profile, indent=2, ensure_ascii=False))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
#: A sync that has not produced a new day in this long is treated as broken, not
|
|
93
|
+
#: as a quiet week: the watch writes a row for every day it is worn.
|
|
94
|
+
#: Imported, not re-declared: the web app shows its staleness banner at the same
|
|
95
|
+
#: number (via `snapshot.assemble`'s `stale_after_days`), and that banner points
|
|
96
|
+
#: the athlete at `runspector doctor` to explain itself. Two literals meant doctor
|
|
97
|
+
#: answered `[ok]` at the very age that raised the banner.
|
|
98
|
+
STALE_AFTER_DAYS = snapshot.STALE_AFTER_DAYS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_doctor(args) -> int:
|
|
102
|
+
"""Everything a first run needs, checked in the order it fails in practice —
|
|
103
|
+
and written so that `runspector doctor || alert` is worth putting in a cron.
|
|
104
|
+
|
|
105
|
+
It used to answer "all green, exit 0" for an installation whose nightly sync
|
|
106
|
+
had died months earlier: it checked that a token FILE existed, never that the
|
|
107
|
+
session still worked, and that the database had EVER held data, never that the
|
|
108
|
+
data was recent. Token expiry freezing the data is the failure mode of this
|
|
109
|
+
design, so those are exactly the two things it now tests."""
|
|
110
|
+
from .store import Store
|
|
111
|
+
|
|
112
|
+
ok = True
|
|
113
|
+
|
|
114
|
+
def check(label: str, good: bool, hint: str = "", *, fatal: bool = True) -> None:
|
|
115
|
+
"""`fatal=False` reports without touching the exit code. The claude CLI is
|
|
116
|
+
optional - the app reads, charts and syncs without it, only the coach
|
|
117
|
+
cards need it - and a cron running `runspector doctor || alert` should page
|
|
118
|
+
on a dead Garmin session, not on a machine that never had Claude Code."""
|
|
119
|
+
nonlocal ok
|
|
120
|
+
if fatal:
|
|
121
|
+
ok &= good
|
|
122
|
+
print(f" [{'ok' if good else '!!'}] {label}"
|
|
123
|
+
+ (f"\n -> {hint}" if not good and hint else ""))
|
|
124
|
+
|
|
125
|
+
# The home directory is the first thing that can be broken, and every check
|
|
126
|
+
# below it depends on it. Unreadable, a read-only volume or a stale NFS mount
|
|
127
|
+
# came out as a traceback - which is the one output shape a diagnostic command
|
|
128
|
+
# must never produce, because it looks like the tool is the bug.
|
|
129
|
+
#
|
|
130
|
+
# The BANNER is inside the guard too. `paths.home()` does the `mkdir`, so
|
|
131
|
+
# printing "data in {paths.home()}" first put the very call that fails ahead
|
|
132
|
+
# of the try - and `RUNSPECTOR_HOME` pointing at an existing FILE still ended in
|
|
133
|
+
# a raw `FileExistsError`, in the version whose comment said it would not.
|
|
134
|
+
print(f"runspector {__version__}")
|
|
135
|
+
try:
|
|
136
|
+
home = paths.home()
|
|
137
|
+
print(f" data in {home}")
|
|
138
|
+
probe = home / ".doctor-probe"
|
|
139
|
+
probe.write_text("x", encoding="utf-8")
|
|
140
|
+
probe.unlink()
|
|
141
|
+
check(f"data directory writable ({home})", True)
|
|
142
|
+
# Left over when both existed at the move: the new one won, and a
|
|
143
|
+
# second copy of the athlete's health data should not linger unnoticed.
|
|
144
|
+
if (program := paths.legacy_program()) is not None:
|
|
145
|
+
check(f"runcoach-ai is still installed ({program})", False,
|
|
146
|
+
"it keeps its own data apart from runspector's: `uv tool uninstall "
|
|
147
|
+
"runcoach-ai`, and point Claude Code / Desktop and cron jobs at runspector",
|
|
148
|
+
fatal=False)
|
|
149
|
+
left = paths.legacy_home()
|
|
150
|
+
if not os.environ.get("RUNSPECTOR_HOME") and left.is_dir():
|
|
151
|
+
# Which one holds a database decides the advice: "delete it" about
|
|
152
|
+
# the directory that has all the data would be the worst sentence
|
|
153
|
+
# this command could print.
|
|
154
|
+
if any((left / name).exists() for name in (paths.LEGACY_DB, "runspector.db")):
|
|
155
|
+
hint = (f"it still holds a database. If runspector shows none of your data, "
|
|
156
|
+
f"stop it, move {paths.display_path(home)} aside and start again - "
|
|
157
|
+
f"the old directory is moved in then")
|
|
158
|
+
else:
|
|
159
|
+
hint = (f"runspector uses {paths.display_path(home)}; delete the old one once "
|
|
160
|
+
f"you no longer need it")
|
|
161
|
+
check(f"the old data directory {paths.display_path(left)} is still there", False,
|
|
162
|
+
hint, fatal=False)
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
where = os.environ.get("RUNSPECTOR_HOME") or "~/.runspector"
|
|
165
|
+
check(f"data directory unusable ({where}: {exc.strerror or exc})", False,
|
|
166
|
+
"point RUNSPECTOR_HOME at a writable directory, or fix the permissions")
|
|
167
|
+
return 1
|
|
168
|
+
if os.name != "nt":
|
|
169
|
+
mode = home.stat().st_mode & 0o777
|
|
170
|
+
# Sleep, HRV, resting heart rate and the Garmin session tokens live here.
|
|
171
|
+
# `paths.home()` only chmods on CREATION, so every install made before
|
|
172
|
+
# that landed still carries the umask default - and nothing said so.
|
|
173
|
+
check(f"data directory mode {mode:o}", mode & 0o077 == 0,
|
|
174
|
+
f"other accounts on this machine can read your health data and Garmin "
|
|
175
|
+
f"session: chmod 700 {home}", fatal=False)
|
|
176
|
+
|
|
177
|
+
tz = os.environ.get("RUNSPECTOR_TZ")
|
|
178
|
+
if tz:
|
|
179
|
+
try:
|
|
180
|
+
paths.local_tz()
|
|
181
|
+
check(f"RUNSPECTOR_TZ={tz}", True)
|
|
182
|
+
except Exception: # noqa: BLE001 — any zoneinfo failure is the same answer
|
|
183
|
+
check(f"RUNSPECTOR_TZ={tz!r} is not a known IANA zone", False,
|
|
184
|
+
"e.g. Europe/Berlin, America/New_York - or unset it for the system zone")
|
|
185
|
+
|
|
186
|
+
claude = shutil.which("claude")
|
|
187
|
+
check("claude CLI on PATH (coach cards run on your Claude Code sign-in)", bool(claude),
|
|
188
|
+
"install Claude Code and sign in: https://claude.com/claude-code", fatal=False)
|
|
189
|
+
if claude:
|
|
190
|
+
try:
|
|
191
|
+
out = subprocess.run([claude, "--version"], # noqa: S603 — the resolved `claude`, fixed args
|
|
192
|
+
capture_output=True, text=True, timeout=20)
|
|
193
|
+
check(f"claude --version: {out.stdout.strip() or out.stderr.strip()}",
|
|
194
|
+
out.returncode == 0, fatal=False)
|
|
195
|
+
except Exception as exc: # noqa: BLE001 — reported as a failed check, not a traceback
|
|
196
|
+
check(f"claude --version ({type(exc).__name__})", False, fatal=False)
|
|
197
|
+
# The subscription is the CLI's own login - the one thing to "connect".
|
|
198
|
+
from .onboarding import api_key_allowed, claude_line, claude_status
|
|
199
|
+
|
|
200
|
+
status = claude_status(fresh=True)
|
|
201
|
+
# No switch in this hint: `other` is measured in the cards' environment,
|
|
202
|
+
# so it is a sign-in the cards already use - the switch would not change
|
|
203
|
+
# that, and for anyone with a plan it only adds a per-token bill.
|
|
204
|
+
check(f"Claude: {claude_line(status)}",
|
|
205
|
+
status in ("subscription", "unknown") or (status == "other" and api_key_allowed()),
|
|
206
|
+
"run `claude auth login` and sign in with your Claude plan", fatal=False)
|
|
207
|
+
|
|
208
|
+
# The app strips these before spawning the agent unless the athlete allowed
|
|
209
|
+
# them. Without a word here that is a trap with no way out: the CLI works in
|
|
210
|
+
# the user's own terminal, every coach card fails in the app, and doctor
|
|
211
|
+
# said [ok] twice.
|
|
212
|
+
from .onboarding import api_key_allowed, strips_billing
|
|
213
|
+
|
|
214
|
+
stripped = sorted(n for n in os.environ if strips_billing(n))
|
|
215
|
+
if stripped and api_key_allowed():
|
|
216
|
+
check(f"billing variables kept for coach cards (RUNSPECTOR_ALLOW_API_KEY=1): "
|
|
217
|
+
f"{', '.join(stripped)}", True)
|
|
218
|
+
elif stripped:
|
|
219
|
+
check(f"billing variables in the environment: {', '.join(stripped)}", False,
|
|
220
|
+
"coach cards run on your Claude Code sign-in and remove these before the "
|
|
221
|
+
"agent starts, so a key exported for other work never bills a card. To "
|
|
222
|
+
"use an API key or Bedrock/Vertex for the cards, set "
|
|
223
|
+
"RUNSPECTOR_ALLOW_API_KEY=1; otherwise unset them to silence this check.",
|
|
224
|
+
fatal=False)
|
|
225
|
+
|
|
226
|
+
# The write path, which `doctor` used not to know existed at all: a package
|
|
227
|
+
# that stopped half way, or a claim whose upload never answered, is exactly
|
|
228
|
+
# the state a runner cannot see and cannot reason about. It is reported, not
|
|
229
|
+
# failed - nothing here is broken, it is a question for the athlete.
|
|
230
|
+
from . import plan
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
store_for_plan = Store()
|
|
234
|
+
unresolved = store_for_plan.unresolved_claims()
|
|
235
|
+
open_n = len(plan.open_proposals(store_for_plan))
|
|
236
|
+
half = [p for p in (plan.read(f.stem, store_for_plan)
|
|
237
|
+
for f in paths.proposals_dir().glob("p-*.json"))
|
|
238
|
+
if p and p.get("status") == "applied" and plan.pending_of(p)]
|
|
239
|
+
# From the DATABASE, not from a file: a proposal swept after the TTL
|
|
240
|
+
# takes the only file-side trace of a half-written session with it, and
|
|
241
|
+
# an uploaded workout that never got a day is the one the athlete will
|
|
242
|
+
# not find on the watch.
|
|
243
|
+
orphans = [w for w in store_for_plan.own_workouts() if not w["schedule_id"]]
|
|
244
|
+
except Exception as exc: # noqa: BLE001 — a diagnostic never tracebacks
|
|
245
|
+
check(f"proposals unreadable ({type(exc).__name__})", False, fatal=False)
|
|
246
|
+
else:
|
|
247
|
+
check(f"proposals: {open_n} waiting for a yes, {len(half)} applied only in part",
|
|
248
|
+
not half,
|
|
249
|
+
"apply them again to add what is missing - what is already on Garmin is not "
|
|
250
|
+
"written twice: " + ", ".join(p["id"] for p in half[:3]), fatal=False)
|
|
251
|
+
if orphans:
|
|
252
|
+
check(f"{len(orphans)} uploaded workout(s) sit in your Garmin library "
|
|
253
|
+
f"without a day", False,
|
|
254
|
+
"schedule them in Garmin Connect, or propose the session again: "
|
|
255
|
+
+ ", ".join(f"{w['name']} ({w['workout_id']})" for w in orphans[:3]),
|
|
256
|
+
fatal=False)
|
|
257
|
+
if unresolved:
|
|
258
|
+
check(f"{len(unresolved)} upload(s) never gave an answer", False,
|
|
259
|
+
"Garmin may or may not have created these - look in your Garmin library, "
|
|
260
|
+
"then propose them again if they are missing: "
|
|
261
|
+
+ ", ".join(f"{u['proposal_id']}#{u['item_index']}" for u in unresolved[:3]),
|
|
262
|
+
fatal=False)
|
|
263
|
+
|
|
264
|
+
# The week planner runs without these - on defaults it lists as assumptions
|
|
265
|
+
# - so this is a hint, not a failure.
|
|
266
|
+
profile = snapshot.load_profile()
|
|
267
|
+
missing = [k for k in ("days_per_week", "long_run_day") if profile.get(k) is None]
|
|
268
|
+
check("profile for the week planner"
|
|
269
|
+
+ ("" if missing else f": {profile['days_per_week']} days, long run {profile['long_run_day']}"),
|
|
270
|
+
not missing,
|
|
271
|
+
f"weekly plans use defaults ({planning.DEFAULT_DAYS_PER_WEEK} days, long run "
|
|
272
|
+
f"{planning.DEFAULT_LONG_RUN_DAY}) until you set: runspector profile "
|
|
273
|
+
f"--days-per-week N --long-run-day mon..sun", fatal=False)
|
|
274
|
+
|
|
275
|
+
if not paths.garmin_session_present():
|
|
276
|
+
check("Garmin session", False, "no tokens stored - run `runspector login`")
|
|
277
|
+
elif args.offline:
|
|
278
|
+
check("Garmin session tokens present (not verified: --offline)", True)
|
|
279
|
+
else:
|
|
280
|
+
from . import garmin
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
garmin.login()
|
|
284
|
+
check("Garmin session still valid", True)
|
|
285
|
+
except Exception as exc: # noqa: BLE001 — the point is to report, not to raise
|
|
286
|
+
check(f"Garmin session rejected ({type(exc).__name__})", False,
|
|
287
|
+
"the stored session has expired - run `runspector login`")
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
store = Store()
|
|
291
|
+
counts, latest = store.counts(), store.latest_day()
|
|
292
|
+
except Exception as exc: # noqa: BLE001 — a corrupt DB is a FINDING, not a crash
|
|
293
|
+
check(f"database unreadable ({type(exc).__name__}: {exc})", False,
|
|
294
|
+
f"the file may be corrupt: move {paths.db_path()} aside and run "
|
|
295
|
+
f"`runspector sync --days 30` to rebuild it")
|
|
296
|
+
return 1
|
|
297
|
+
if latest is None:
|
|
298
|
+
check(f"database schema v{store.schema_version()} - empty", False,
|
|
299
|
+
"run `runspector sync --days 30` (or try `runspector serve --demo`)")
|
|
300
|
+
else:
|
|
301
|
+
behind = (paths.today() - latest).days
|
|
302
|
+
check(f"database schema v{store.schema_version()} - {counts['days']} days, "
|
|
303
|
+
f"{counts['activities']} activities, newest day {latest} ({behind} day(s) behind)",
|
|
304
|
+
# `<`, not `<=`. The page raises its banner at `stale_days >=
|
|
305
|
+
# STALE_AFTER_DAYS` and tells the athlete to run this command to
|
|
306
|
+
# explain it - so at EXACTLY the threshold, `<=` had doctor answer
|
|
307
|
+
# `[ok]` to a question the page had just raised. Sharing the
|
|
308
|
+
# constant fixed the number and left the comparison: the same
|
|
309
|
+
# contradiction, moved from 2 days to 3.
|
|
310
|
+
behind < STALE_AFTER_DAYS,
|
|
311
|
+
"the data is stale - run `runspector sync` and check that your scheduled "
|
|
312
|
+
"sync still runs")
|
|
313
|
+
return 0 if ok else 1
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv: list[str] | None = None) -> int:
|
|
317
|
+
_utf8()
|
|
318
|
+
# Before anything reads a variable: `RUNCOACH_LOG` set for v0.4 must still
|
|
319
|
+
# configure the logging two lines down.
|
|
320
|
+
taken = paths.adopt_legacy_env()
|
|
321
|
+
# `.upper()` because `RUNSPECTOR_LOG=debug` is the natural spelling and used to
|
|
322
|
+
# kill every subcommand with a raw `ValueError: Unknown level: 'debug'`.
|
|
323
|
+
logging.basicConfig(level=(os.environ.get("RUNSPECTOR_LOG") or "WARNING").upper(),
|
|
324
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s", stream=sys.stderr)
|
|
325
|
+
ap = argparse.ArgumentParser(prog="runspector", description=__doc__,
|
|
326
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
327
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
328
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
329
|
+
|
|
330
|
+
p = sub.add_parser("login", help="one-time Garmin login (interactive, MFA)")
|
|
331
|
+
p.add_argument("--verify", action="store_true", help="only test the stored session")
|
|
332
|
+
p.set_defaults(fn=cmd_login)
|
|
333
|
+
|
|
334
|
+
p = sub.add_parser("sync", help="pull the last N days from Garmin",
|
|
335
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
336
|
+
description="Exit 0 fine, 2 Garmin stopped us, 3 partial.")
|
|
337
|
+
p.add_argument("--days", type=int, default=3, help="how many days back to re-fetch")
|
|
338
|
+
p.set_defaults(fn=cmd_sync)
|
|
339
|
+
|
|
340
|
+
p = sub.add_parser("serve", help="start the web app and sync in the background",
|
|
341
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
342
|
+
p.add_argument("--host", default="127.0.0.1",
|
|
343
|
+
help="anything but loopback requires RUNSPECTOR_TOKEN")
|
|
344
|
+
p.add_argument("--port", type=int, default=8765, help="TCP port to listen on")
|
|
345
|
+
p.add_argument("--demo", action="store_true", help="synthetic data, no Garmin account needed")
|
|
346
|
+
p.add_argument("--no-browser", action="store_true", help="do not open a browser window")
|
|
347
|
+
p.add_argument("--no-sync", action="store_true", help="skip the sync on start")
|
|
348
|
+
p.set_defaults(fn=cmd_serve)
|
|
349
|
+
|
|
350
|
+
p = sub.add_parser("mcp", help="stdio MCP server for Claude Code / Claude Desktop")
|
|
351
|
+
p.set_defaults(fn=cmd_mcp)
|
|
352
|
+
|
|
353
|
+
p = sub.add_parser("profile", help="show or set the optional athlete profile")
|
|
354
|
+
p.add_argument("--max-hr", type=int, dest="max_hr", help="measured maximum heart rate")
|
|
355
|
+
p.add_argument("--aerobic-ref-hr", type=int, dest="aerobic_ref_hr",
|
|
356
|
+
help="reference HR for the aerobic-efficiency card")
|
|
357
|
+
p.add_argument("--goal", help="free text, shown on the Today tab")
|
|
358
|
+
p.add_argument("--days-per-week", type=int, choices=range(2, 8), dest="days_per_week",
|
|
359
|
+
metavar="N", help="running days per week, for the week planner (2-7); "
|
|
360
|
+
"without it the planner reads your history")
|
|
361
|
+
p.add_argument("--long-run-day", choices=planning.WEEKDAYS, dest="long_run_day",
|
|
362
|
+
help="weekday of the long run, for the week planner; without it the "
|
|
363
|
+
"planner reads your history")
|
|
364
|
+
p.set_defaults(fn=cmd_profile)
|
|
365
|
+
|
|
366
|
+
p = sub.add_parser("doctor", help="check the installation (exit 1 = something is wrong)")
|
|
367
|
+
p.add_argument("--offline", action="store_true",
|
|
368
|
+
help="skip the Garmin session check (no network)")
|
|
369
|
+
p.set_defaults(fn=cmd_doctor)
|
|
370
|
+
|
|
371
|
+
args = ap.parse_args(argv)
|
|
372
|
+
if taken:
|
|
373
|
+
print(f"note: {', '.join(taken)} - read as RUNSPECTOR_* for now; rename them, "
|
|
374
|
+
f"the old names stop working in v0.6", file=sys.stderr)
|
|
375
|
+
# Every subcommand, `mcp` included: a Claude Code config that starts the
|
|
376
|
+
# server is often the first thing to run after the upgrade.
|
|
377
|
+
moved, refused = paths.migrate_legacy_home()
|
|
378
|
+
for line in moved:
|
|
379
|
+
print(f"moved your runcoach data: {line}", file=sys.stderr)
|
|
380
|
+
if refused:
|
|
381
|
+
print(refused, file=sys.stderr)
|
|
382
|
+
return 1
|
|
383
|
+
return args.fn(args)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
sys.exit(main())
|