cctally 1.63.0 → 1.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +7 -0
- package/bin/_cctally_cache.py +60 -19
- package/bin/_cctally_config.py +68 -0
- package/bin/_cctally_core.py +29 -0
- package/bin/_cctally_dashboard.py +364 -38
- package/bin/_cctally_doctor.py +24 -0
- package/bin/_cctally_parser.py +61 -0
- package/bin/_cctally_setup.py +22 -0
- package/bin/_cctally_telemetry.py +362 -0
- package/bin/_cctally_tui.py +445 -236
- package/bin/_cctally_update.py +53 -9
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_doctor.py +32 -0
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/bin/cctally +98 -0
- package/bin/cctally-npm-postinstall.js +4 -0
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Anonymous install-count telemetry kernel (see spec 2026-07-07).
|
|
2
|
+
|
|
3
|
+
Pure-ish: state resolution, token derivation, and payload construction are
|
|
4
|
+
side-effect-free; only the beat path mints an ``install_id`` / touches the
|
|
5
|
+
on-disk markers / makes a network call. Stdlib only.
|
|
6
|
+
|
|
7
|
+
Privacy posture (spec 2026-07-07):
|
|
8
|
+
- No IP, no username, no path, no session content ever leaves the machine.
|
|
9
|
+
- The only durable identifier is a random UUID ``install_id`` stored 0600
|
|
10
|
+
under APP_DIR; it is NEVER transmitted. What we send is a per-month
|
|
11
|
+
rotating token ``sha256(install_id:YYYY-MM:pepper)[:32]`` — one-way and
|
|
12
|
+
unlinkable across months, so the server can de-dupe within a month
|
|
13
|
+
without ever seeing the id.
|
|
14
|
+
- Fully opt-out: ``CCTALLY_DISABLE_TELEMETRY``, the ``DO_NOT_TRACK``
|
|
15
|
+
convention, dev checkouts, and a ``telemetry.enabled = false`` config
|
|
16
|
+
key each disable it (see ``resolve_telemetry_state``).
|
|
17
|
+
- Network errors are swallowed — telemetry never affects the user's UX.
|
|
18
|
+
|
|
19
|
+
Cross-module symbols (``_is_dev_checkout``, ``resolve_client_version``,
|
|
20
|
+
``resolve_os_family``, ``_release_read_latest_release_version``) are reached
|
|
21
|
+
through the call-time ``_cctally()`` accessor so tests' ``monkeypatch`` on the
|
|
22
|
+
``cctally`` module namespace propagates into these bodies. Path/constant
|
|
23
|
+
reads go through the ``_core()`` accessor so a redirected APP_DIR (tests,
|
|
24
|
+
dev-instance isolation) is honored.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import datetime as _dt
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import platform
|
|
34
|
+
import sys
|
|
35
|
+
import uuid
|
|
36
|
+
import urllib.request
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cctally():
|
|
40
|
+
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
41
|
+
return sys.modules["cctally"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _core():
|
|
45
|
+
import _cctally_core
|
|
46
|
+
return _cctally_core
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _truthy_env(name: str) -> bool:
|
|
50
|
+
"""A ``1``/``true``/``yes``/any-non-empty env value is truthy; unset,
|
|
51
|
+
empty, ``0``, ``false``, ``no`` are falsey."""
|
|
52
|
+
v = os.environ.get(name)
|
|
53
|
+
return v is not None and v.strip().lower() not in ("", "0", "false", "no")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_telemetry_state(config: dict) -> tuple[bool, str]:
|
|
57
|
+
"""Return ``(enabled, reason)`` — side-effect-free.
|
|
58
|
+
|
|
59
|
+
Precedence (first match wins): env opt-out, DO_NOT_TRACK, dev checkout,
|
|
60
|
+
config opt-out, else enabled. Never mints or touches any file.
|
|
61
|
+
"""
|
|
62
|
+
c = _cctally()
|
|
63
|
+
if _truthy_env("CCTALLY_DISABLE_TELEMETRY"):
|
|
64
|
+
return (False, "env-disabled")
|
|
65
|
+
if _truthy_env("DO_NOT_TRACK"):
|
|
66
|
+
return (False, "do-not-track")
|
|
67
|
+
if c._is_dev_checkout():
|
|
68
|
+
return (False, "dev-checkout")
|
|
69
|
+
tele = (config or {}).get("telemetry") or {}
|
|
70
|
+
if tele.get("enabled") is False:
|
|
71
|
+
return (False, "config-disabled")
|
|
72
|
+
return (True, "enabled")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def current_period(now: _dt.datetime | None = None) -> str:
|
|
76
|
+
"""Current rotation period as ``"YYYY-MM"`` in UTC."""
|
|
77
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
78
|
+
return now.astimezone(_dt.timezone.utc).strftime("%Y-%m")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def telemetry_token(install_id: str, period: str) -> str:
|
|
82
|
+
"""One-way, month-rotating token: ``sha256(id:period:pepper)[:32]``.
|
|
83
|
+
|
|
84
|
+
Deterministic within a period, unlinkable across periods, and never
|
|
85
|
+
reveals ``install_id``."""
|
|
86
|
+
pepper = _core().TELEMETRY_PEPPER
|
|
87
|
+
raw = f"{install_id}:{period}:{pepper}".encode("utf-8")
|
|
88
|
+
return hashlib.sha256(raw).hexdigest()[:32]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_install_id() -> str | None:
|
|
92
|
+
"""Read the persisted install_id, or ``None`` if absent/empty. Never mints."""
|
|
93
|
+
p = _core().TELEMETRY_INSTALL_ID_PATH
|
|
94
|
+
try:
|
|
95
|
+
return p.read_text(encoding="utf-8").strip() or None
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ensure_install_id() -> str:
|
|
101
|
+
"""Return the install_id, minting a random UUID at 0600 if missing."""
|
|
102
|
+
p = _core().TELEMETRY_INSTALL_ID_PATH
|
|
103
|
+
existing = read_install_id()
|
|
104
|
+
if existing:
|
|
105
|
+
return existing
|
|
106
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
iid = str(uuid.uuid4())
|
|
108
|
+
# Atomically create at 0600 (O_EXCL) rather than write-then-chmod, so the
|
|
109
|
+
# id file is never briefly world-readable in the umask window. install_id
|
|
110
|
+
# is a non-secret, resettable UUID that never leaves the machine, but 0600
|
|
111
|
+
# matches the cache-db sidecar-hardening posture. On a create race the
|
|
112
|
+
# loser gets FileExistsError and reads the winner's value.
|
|
113
|
+
try:
|
|
114
|
+
fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
115
|
+
except FileExistsError:
|
|
116
|
+
return read_install_id() or iid
|
|
117
|
+
except OSError:
|
|
118
|
+
# Any other open failure: fall back to a best-effort plain write.
|
|
119
|
+
p.write_text(iid + "\n", encoding="utf-8")
|
|
120
|
+
return iid
|
|
121
|
+
try:
|
|
122
|
+
os.write(fd, (iid + "\n").encode("utf-8"))
|
|
123
|
+
finally:
|
|
124
|
+
os.close(fd)
|
|
125
|
+
return iid
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reset_install_id() -> str:
|
|
129
|
+
"""Discard any existing install_id and mint a fresh one."""
|
|
130
|
+
p = _core().TELEMETRY_INSTALL_ID_PATH
|
|
131
|
+
try:
|
|
132
|
+
p.unlink()
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
return ensure_install_id()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_client_version() -> str:
|
|
139
|
+
"""The stamped client semver, or ``"unknown"`` when unstamped."""
|
|
140
|
+
c = _cctally()
|
|
141
|
+
cur = c._release_read_latest_release_version()
|
|
142
|
+
return cur[0] if cur else "unknown"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_os_family() -> str:
|
|
146
|
+
"""Coarse OS family: ``macos`` | ``linux`` | ``windows`` | ``other``."""
|
|
147
|
+
s = platform.system().lower()
|
|
148
|
+
return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(s, "other")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_beat_payload(install_id: str, *, now: _dt.datetime | None = None) -> dict:
|
|
152
|
+
"""The minimal beat body: ``{t, v, os}`` — token, client version, OS family.
|
|
153
|
+
|
|
154
|
+
Version/OS are read through the ``cctally`` accessor so a test's
|
|
155
|
+
``monkeypatch.setattr(cctally, "resolve_client_version", ...)`` drives the
|
|
156
|
+
output (the re-export lives on the ``cctally`` module, not this kernel's
|
|
157
|
+
namespace)."""
|
|
158
|
+
c = _cctally()
|
|
159
|
+
return {
|
|
160
|
+
"t": telemetry_token(install_id, current_period(now)),
|
|
161
|
+
"v": c.resolve_client_version(),
|
|
162
|
+
"os": c.resolve_os_family(),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _marker_age_seconds(path, now: _dt.datetime | None = None) -> float | None:
|
|
167
|
+
"""Seconds since ``path``'s mtime, or ``None`` when the marker is absent."""
|
|
168
|
+
try:
|
|
169
|
+
mtime = path.stat().st_mtime
|
|
170
|
+
except OSError:
|
|
171
|
+
return None
|
|
172
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
173
|
+
return now.timestamp() - mtime
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def telemetry_beat_due(now=None) -> bool:
|
|
177
|
+
"""True when no beat has been *attempted* within the throttle window.
|
|
178
|
+
|
|
179
|
+
The last-beat marker records the last beat ATTEMPT (not just the last
|
|
180
|
+
successful send): ``do_telemetry_beat`` stamps it on every non-disabled
|
|
181
|
+
run — arm, grace, sent, or failed — so this predicate bounds the parent
|
|
182
|
+
spawn gate to at most one worker per window regardless of outcome (a
|
|
183
|
+
network outage / undeployed endpoint can't churn re-spawns)."""
|
|
184
|
+
age = _marker_age_seconds(_core().TELEMETRY_LAST_BEAT_PATH, now)
|
|
185
|
+
return age is None or age >= _core().TELEMETRY_BEAT_THROTTLE_SECONDS
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def first_beat_grace_elapsed(now=None) -> bool:
|
|
189
|
+
"""True once the first-seen marker is at least the grace window old."""
|
|
190
|
+
age = _marker_age_seconds(_core().TELEMETRY_FIRST_SEEN_PATH, now)
|
|
191
|
+
return age is not None and age >= _core().TELEMETRY_FIRST_BEAT_GRACE_SECONDS
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _touch(path) -> None:
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
path.touch()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def mark_first_seen(now=None) -> None:
|
|
200
|
+
"""Arm the first-seen marker once; subsequent calls are no-ops so the
|
|
201
|
+
grace window is measured from genuine first eligibility."""
|
|
202
|
+
p = _core().TELEMETRY_FIRST_SEEN_PATH
|
|
203
|
+
if _marker_age_seconds(p) is None:
|
|
204
|
+
_touch(p)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def touch_last_beat(now=None) -> None:
|
|
208
|
+
"""Stamp the last-beat-attempt marker to now (resets the throttle window).
|
|
209
|
+
|
|
210
|
+
Called at the top of every non-disabled ``do_telemetry_beat`` run so the
|
|
211
|
+
marker tracks the last ATTEMPT, not just the last successful send."""
|
|
212
|
+
_touch(_core().TELEMETRY_LAST_BEAT_PATH)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def notice_already_shown() -> bool:
|
|
216
|
+
return _core().TELEMETRY_NOTICE_SHOWN_PATH.exists()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def mark_notice_shown() -> None:
|
|
220
|
+
_touch(_core().TELEMETRY_NOTICE_SHOWN_PATH)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _endpoint() -> str:
|
|
224
|
+
return os.environ.get("CCTALLY_TELEMETRY_ENDPOINT") or _core().TELEMETRY_ENDPOINT_DEFAULT
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def do_telemetry_beat(config: dict, *, now=None, endpoint=None) -> str:
|
|
228
|
+
"""Orchestrate arm -> grace -> beat. Returns a status string:
|
|
229
|
+
|
|
230
|
+
``disabled:<reason>`` (opt-out), ``armed`` (first eligibility recorded),
|
|
231
|
+
``grace`` (still inside the first-beat grace window), ``sent`` (POST
|
|
232
|
+
succeeded), or ``failed`` (network error, swallowed).
|
|
233
|
+
|
|
234
|
+
Throttling to at most one beat per window is enforced at the PARENT
|
|
235
|
+
spawn gate (``_post_command_update_hooks``), which only spawns this
|
|
236
|
+
worker when ``telemetry_beat_due()`` is True. To make that bound hold
|
|
237
|
+
regardless of outcome, the last-beat-attempt marker is stamped FIRST —
|
|
238
|
+
immediately after the opt-out check, before the arm/grace/send branches
|
|
239
|
+
(mirroring ``_do_update_check``'s touch-the-marker-first crash-safety
|
|
240
|
+
pattern). Without this, the marker was stamped only on a successful
|
|
241
|
+
send, so the parent gate stayed True through the entire 24h grace
|
|
242
|
+
window and through any sustained outage (e.g. the endpoint not yet
|
|
243
|
+
deployed) — re-spawning a fresh detached worker on EVERY command,
|
|
244
|
+
including hot paths (statusline/record-usage/hook-tick).
|
|
245
|
+
|
|
246
|
+
Only the network POST is wrapped, so this swallows connection / DNS /
|
|
247
|
+
HTTP / timeout errors from the beat send (returning ``failed``); it is
|
|
248
|
+
not a blanket "never raises" — a defect in the pure marker/id helpers
|
|
249
|
+
would still surface (and the ``_telemetry-beat`` worker wraps the whole
|
|
250
|
+
call in its own try/except as belt-and-suspenders)."""
|
|
251
|
+
enabled, reason = resolve_telemetry_state(config)
|
|
252
|
+
if not enabled:
|
|
253
|
+
return f"disabled:{reason}"
|
|
254
|
+
# Stamp the last-beat-attempt marker FIRST (crash-safe, outcome-
|
|
255
|
+
# independent) so the parent spawn gate is bounded to <=1 worker per
|
|
256
|
+
# throttle window whether this run arms, waits out grace, sends, or
|
|
257
|
+
# fails. The internal ``telemetry_beat_due`` re-check that used to sit
|
|
258
|
+
# below the grace branch is intentionally gone: the marker was just
|
|
259
|
+
# touched, so it would always read fresh here.
|
|
260
|
+
touch_last_beat(now)
|
|
261
|
+
# Arm on first eligibility; do NOT beat until the grace window elapses.
|
|
262
|
+
if _marker_age_seconds(_core().TELEMETRY_FIRST_SEEN_PATH) is None:
|
|
263
|
+
mark_first_seen(now)
|
|
264
|
+
ensure_install_id()
|
|
265
|
+
return "armed"
|
|
266
|
+
if not first_beat_grace_elapsed(now):
|
|
267
|
+
return "grace"
|
|
268
|
+
iid = ensure_install_id()
|
|
269
|
+
payload = build_beat_payload(iid, now=now)
|
|
270
|
+
try:
|
|
271
|
+
req = urllib.request.Request(
|
|
272
|
+
endpoint or _endpoint(),
|
|
273
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
274
|
+
headers={
|
|
275
|
+
"Content-Type": "application/json",
|
|
276
|
+
"User-Agent": f"cctally-telemetry/{payload['v']}",
|
|
277
|
+
},
|
|
278
|
+
method="POST",
|
|
279
|
+
)
|
|
280
|
+
with urllib.request.urlopen(req, timeout=3):
|
|
281
|
+
pass
|
|
282
|
+
# NOTE: the marker was already stamped at the top of this function
|
|
283
|
+
# (touch-first), so no second touch is needed on the success path.
|
|
284
|
+
return "sent"
|
|
285
|
+
except Exception:
|
|
286
|
+
return "failed" # swallow — telemetry never affects UX
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _config_set_ns(key: str, value: str) -> argparse.Namespace:
|
|
290
|
+
"""Synthesize the ``argparse.Namespace`` ``cmd_config``'s ``set`` path
|
|
291
|
+
consumes (``action``/``key``/``value``/``emit_json``). Routing ``on``/``off``
|
|
292
|
+
through the real config setter keeps a single validation + locking
|
|
293
|
+
chokepoint rather than re-implementing the read-modify-write here."""
|
|
294
|
+
return argparse.Namespace(action="set", key=key, value=value, emit_json=False)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_telemetry(args) -> int:
|
|
298
|
+
"""`cctally telemetry [on|off|reset]` + bare status (`--json`).
|
|
299
|
+
|
|
300
|
+
``on``/``off`` flip the ``telemetry.enabled`` config key through the real
|
|
301
|
+
``cmd_config`` setter (its bool validation + atomic write). ``reset`` mints
|
|
302
|
+
a fresh ``install_id``. The bare/status path is strictly READ-ONLY — it
|
|
303
|
+
resolves the opt-out state from a RAW guarded ``config.json`` read (NOT
|
|
304
|
+
``load_config()``, which would auto-create config.json on a fresh install)
|
|
305
|
+
and previews the month token WITHOUT ever minting an id (it calls
|
|
306
|
+
``read_install_id``, never ``ensure_install_id``). It writes nothing.
|
|
307
|
+
"""
|
|
308
|
+
c = _cctally()
|
|
309
|
+
action = getattr(args, "action", None)
|
|
310
|
+
if action == "off":
|
|
311
|
+
return c.cmd_config(_config_set_ns("telemetry.enabled", "false"))
|
|
312
|
+
if action == "on":
|
|
313
|
+
return c.cmd_config(_config_set_ns("telemetry.enabled", "true"))
|
|
314
|
+
if action == "reset":
|
|
315
|
+
reset_install_id()
|
|
316
|
+
print("telemetry: install id reset")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
# bare / status — strictly read-only. Resolve the opt-out state from a
|
|
320
|
+
# RAW guarded config read (mirroring _cctally_doctor's telemetry gather),
|
|
321
|
+
# NOT load_config(): load_config() calls ensure_dirs() and auto-creates
|
|
322
|
+
# config.json on a fresh install, which would contradict the documented
|
|
323
|
+
# "strictly read-only" / "never mints an install_id, writes config, or
|
|
324
|
+
# sends a beat" contract (docs/telemetry.md, docs/commands/telemetry.md).
|
|
325
|
+
# A missing/corrupt config degrades to `{}` (env/dev precedence still
|
|
326
|
+
# resolves correctly).
|
|
327
|
+
raw_config: dict = {}
|
|
328
|
+
try:
|
|
329
|
+
cfg_path = _core().CONFIG_PATH
|
|
330
|
+
if cfg_path.exists():
|
|
331
|
+
loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
332
|
+
if isinstance(loaded, dict):
|
|
333
|
+
raw_config = loaded
|
|
334
|
+
except Exception:
|
|
335
|
+
raw_config = {}
|
|
336
|
+
enabled, reason = resolve_telemetry_state(raw_config)
|
|
337
|
+
iid = read_install_id()
|
|
338
|
+
period = current_period()
|
|
339
|
+
token = telemetry_token(iid, period) if iid else None
|
|
340
|
+
info = {
|
|
341
|
+
"enabled": enabled,
|
|
342
|
+
"reason": reason,
|
|
343
|
+
"version": c.resolve_client_version(),
|
|
344
|
+
"os": c.resolve_os_family(),
|
|
345
|
+
"period": period,
|
|
346
|
+
"token_preview": token,
|
|
347
|
+
"fields": ["token", "version", "os"],
|
|
348
|
+
}
|
|
349
|
+
if getattr(args, "json", False):
|
|
350
|
+
print(json.dumps(info))
|
|
351
|
+
return 0
|
|
352
|
+
print(f"telemetry: {'enabled' if enabled else 'disabled'} ({reason})")
|
|
353
|
+
print(
|
|
354
|
+
f" sends: rotating monthly token + version ({info['version']}) "
|
|
355
|
+
f"+ os ({info['os']})"
|
|
356
|
+
)
|
|
357
|
+
print(f" token this month: {token or '(not yet armed)'}")
|
|
358
|
+
print(
|
|
359
|
+
" opt out: cctally telemetry off | "
|
|
360
|
+
"CCTALLY_DISABLE_TELEMETRY=1 | DO_NOT_TRACK=1"
|
|
361
|
+
)
|
|
362
|
+
return 0
|