cctally 1.78.0 → 1.79.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 +13 -0
- package/README.md +2 -0
- package/bin/_cctally_cache.py +140 -2
- package/bin/_cctally_config.py +50 -0
- package/bin/_cctally_dashboard.py +175 -20
- package/bin/_cctally_dashboard_conversation.py +1 -1
- package/bin/_cctally_dashboard_envelope.py +12 -0
- package/bin/_cctally_dashboard_sources.py +15 -1
- package/bin/_cctally_doctor.py +16 -1
- package/bin/_cctally_milestone_history.py +845 -0
- package/bin/_cctally_tui.py +23 -0
- package/bin/_cctally_update.py +338 -15
- package/bin/_cctally_weekrefs.py +6 -2
- package/bin/_lib_codex_conversation.py +1342 -18
- package/bin/_lib_codex_conversation_export.py +21 -1
- package/bin/_lib_codex_conversation_query.py +530 -67
- package/bin/_lib_conversation_dispatch.py +2 -2
- package/bin/_lib_doctor.py +38 -0
- package/bin/_lib_milestone_history.py +158 -0
- package/bin/cctally +31 -0
- package/dashboard/static/assets/index-BwvAcS_k.js +92 -0
- package/dashboard/static/assets/index-CrIlpAlQ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-CkTe6VJt.js +0 -87
- package/dashboard/static/assets/index-DsEXuMpq.css +0 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -1066,6 +1066,13 @@ class DataSnapshot:
|
|
|
1066
1066
|
# at sync-thread time so ``snapshot_to_envelope`` stays a pure
|
|
1067
1067
|
# renderer; empty list when no current 5h block is bound.
|
|
1068
1068
|
five_hour_milestones: list[dict] = field(default_factory=list)
|
|
1069
|
+
# ---- hero-modal historical milestones (spec §1a/§3) ----
|
|
1070
|
+
# Compact per-week navigation index for the CurrentWeekModal's week
|
|
1071
|
+
# chip (build_claude_week_index). Built ONLY on the non-idle rebuild
|
|
1072
|
+
# and stored here so ``snapshot_to_envelope`` stays a pure serializer;
|
|
1073
|
+
# the idle path carries it forward via ``dataclasses.replace``. Empty
|
|
1074
|
+
# list on first paint / when the DB read fails.
|
|
1075
|
+
week_index: list[dict] = field(default_factory=list)
|
|
1069
1076
|
# ---- view-model unification (Bundle 1): pre-computed totals ----
|
|
1070
1077
|
# Populated by the sync thread as sum-over-visible-rows over the
|
|
1071
1078
|
# panel rows ``_dashboard_build_{daily,monthly,weekly}_periods``
|
|
@@ -3180,6 +3187,18 @@ def _tui_build_snapshot(
|
|
|
3180
3187
|
fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
|
|
3181
3188
|
except Exception as exc:
|
|
3182
3189
|
errors.append(f"five-hour-milestones: {exc}")
|
|
3190
|
+
# ---- hero-modal historical milestones week index (spec §1a/§3) ----
|
|
3191
|
+
# Built ONLY here on the non-idle rebuild (the idle short-circuit
|
|
3192
|
+
# returns before this phase and carries the prior index forward via
|
|
3193
|
+
# ``dataclasses.replace``), so an idle dashboard issues NONE of the
|
|
3194
|
+
# index queries. Reached via the ``cctally`` namespace so tests can
|
|
3195
|
+
# monkeypatch it (a call-counter drives the hot-path guard).
|
|
3196
|
+
week_index: list[dict] = []
|
|
3197
|
+
with _perf.phase("build.week_index"):
|
|
3198
|
+
try:
|
|
3199
|
+
week_index = sys.modules["cctally"].build_claude_week_index(conn)
|
|
3200
|
+
except Exception as exc:
|
|
3201
|
+
errors.append(f"week-index: {exc}")
|
|
3183
3202
|
# ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
|
|
3184
3203
|
# Per-tick aggregation lives on the sync thread; the dashboard's
|
|
3185
3204
|
# pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
|
|
@@ -3376,6 +3395,7 @@ def _tui_build_snapshot(
|
|
|
3376
3395
|
daily_panel=daily_panel,
|
|
3377
3396
|
alerts=alerts,
|
|
3378
3397
|
five_hour_milestones=fh_milestones,
|
|
3398
|
+
week_index=week_index,
|
|
3379
3399
|
daily_total_cost_usd=daily_total_cost_usd,
|
|
3380
3400
|
daily_total_tokens=daily_total_tokens,
|
|
3381
3401
|
monthly_total_cost_usd=monthly_total_cost_usd,
|
|
@@ -4165,6 +4185,9 @@ class _TuiSyncThread:
|
|
|
4165
4185
|
last_sync_error=f"sync crashed: {exc}",
|
|
4166
4186
|
generated_at=dt.datetime.now(dt.timezone.utc),
|
|
4167
4187
|
percent_milestones=prev.percent_milestones,
|
|
4188
|
+
# Carry the navigation index forward on a sync crash so the
|
|
4189
|
+
# hero modal's week chip stays usable (spec §3 idle-carry).
|
|
4190
|
+
week_index=prev.week_index,
|
|
4168
4191
|
weekly_history=prev.weekly_history,
|
|
4169
4192
|
weekly_periods=prev.weekly_periods,
|
|
4170
4193
|
monthly_periods=prev.monthly_periods,
|
package/bin/_cctally_update.py
CHANGED
|
@@ -316,7 +316,16 @@ class UpdateCheckRateLimited(UpdateError):
|
|
|
316
316
|
|
|
317
317
|
|
|
318
318
|
class UpdateCheckHTTPError(UpdateError):
|
|
319
|
-
"""Non-200, non-429 HTTP status from a version-check endpoint.
|
|
319
|
+
"""Non-200, non-429 HTTP status from a version-check endpoint.
|
|
320
|
+
|
|
321
|
+
Carries the numeric ``status_code`` (when known) so the channel-aware
|
|
322
|
+
dist-tag fetch can distinguish an exact 404 (dist-tag absent →
|
|
323
|
+
transition-window fallback) from any other HTTP failure (which fails
|
|
324
|
+
the whole resolution, preserving last-known-good)."""
|
|
325
|
+
|
|
326
|
+
def __init__(self, message: str, *, status_code: "int | None" = None) -> None:
|
|
327
|
+
super().__init__(message)
|
|
328
|
+
self.status_code = status_code
|
|
320
329
|
|
|
321
330
|
|
|
322
331
|
class UpdateCheckParseError(UpdateError):
|
|
@@ -387,6 +396,58 @@ def _validate_update_check_ttl_hours_value(raw) -> int:
|
|
|
387
396
|
return n
|
|
388
397
|
|
|
389
398
|
|
|
399
|
+
# === update.channel config (beta-channel opt-in, spec 2026-07-21 §3) ======
|
|
400
|
+
# Distinct from the preview `channel` (prod|preview) in doctor / install.mode:
|
|
401
|
+
# this is the RELEASE channel the client tracks (stable|latest dist-tag vs the
|
|
402
|
+
# beta dist-tag). Internal name is `update_channel`; the config leaf is
|
|
403
|
+
# `update.channel`; API/SSE payloads expose `configured_channel`.
|
|
404
|
+
UPDATE_CHANNELS = ("stable", "beta")
|
|
405
|
+
UPDATE_CHANNEL_DEFAULT = "stable"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _validate_update_channel_value(raw) -> str:
|
|
409
|
+
"""Validate the `update.channel` config value.
|
|
410
|
+
|
|
411
|
+
Accepts a string (whitespace-trimmed, case-insensitive) in
|
|
412
|
+
:data:`UPDATE_CHANNELS`; returns the canonical lower-case form. Any
|
|
413
|
+
other value raises :class:`ValueError` so the CLI (`_cmd_config_set`)
|
|
414
|
+
and the dashboard (`_handle_post_settings`) can map it to their own
|
|
415
|
+
exit-code / HTTP-status semantics — mirrors the update.check
|
|
416
|
+
validators above.
|
|
417
|
+
"""
|
|
418
|
+
if isinstance(raw, str):
|
|
419
|
+
v = raw.strip().lower()
|
|
420
|
+
if v in UPDATE_CHANNELS:
|
|
421
|
+
return v
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"invalid update.channel: {raw!r} "
|
|
424
|
+
f"(expected {'|'.join(UPDATE_CHANNELS)})"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def resolve_update_channel(config: dict) -> str:
|
|
429
|
+
"""Return the configured release channel from `config`, defaulting to
|
|
430
|
+
``"stable"``.
|
|
431
|
+
|
|
432
|
+
Fail-soft: a missing/non-dict `update` block, an absent `channel`
|
|
433
|
+
leaf, or a hand-edited junk value all surface the default (mirrors
|
|
434
|
+
the `dashboard.bind` / `update.check.*` read posture in
|
|
435
|
+
`_config_known_value`). The single source of truth for "which channel
|
|
436
|
+
is configured" across the check pipeline, install path, banner,
|
|
437
|
+
doctor, and the dashboard envelope.
|
|
438
|
+
"""
|
|
439
|
+
block = config.get("update") if isinstance(config, dict) else None
|
|
440
|
+
if not isinstance(block, dict):
|
|
441
|
+
return UPDATE_CHANNEL_DEFAULT
|
|
442
|
+
stored = block.get("channel")
|
|
443
|
+
if stored is None:
|
|
444
|
+
return UPDATE_CHANNEL_DEFAULT
|
|
445
|
+
try:
|
|
446
|
+
return _validate_update_channel_value(stored)
|
|
447
|
+
except ValueError:
|
|
448
|
+
return UPDATE_CHANNEL_DEFAULT
|
|
449
|
+
|
|
450
|
+
|
|
390
451
|
# === update-subcommand state-file / lock / log helpers (spec §1) =========
|
|
391
452
|
# These live next to load_config / save_config because they share the
|
|
392
453
|
# atomic-write idiom (PID-suffixed tmp + os.replace) and the schema-
|
|
@@ -973,7 +1034,7 @@ def _fetch_url(url: str, *, timeout: float | None = None) -> tuple[int, bytes]:
|
|
|
973
1034
|
except urllib.error.HTTPError as e:
|
|
974
1035
|
if e.code == 429:
|
|
975
1036
|
raise UpdateCheckRateLimited(str(e))
|
|
976
|
-
raise UpdateCheckHTTPError(f"HTTP {e.code}: {e}")
|
|
1037
|
+
raise UpdateCheckHTTPError(f"HTTP {e.code}: {e}", status_code=e.code)
|
|
977
1038
|
except (urllib.error.URLError, TimeoutError) as e:
|
|
978
1039
|
# URLError covers connection-setup failures; TimeoutError
|
|
979
1040
|
# (socket.timeout's alias since 3.10) covers stalls during
|
|
@@ -1001,6 +1062,98 @@ def _check_npm_latest_version() -> str:
|
|
|
1001
1062
|
raise UpdateCheckParseError(f"npm registry parse failed: {e}")
|
|
1002
1063
|
|
|
1003
1064
|
|
|
1065
|
+
def _fetch_npm_dist_tag_version(tag: str) -> "str | None":
|
|
1066
|
+
"""Fetch the version bound to an npm dist-tag (`latest`/`beta`).
|
|
1067
|
+
|
|
1068
|
+
Returns the manifest's ``version`` field, or ``None`` **iff** the
|
|
1069
|
+
registry returns an exact HTTP 404 for the tag — i.e. the dist-tag
|
|
1070
|
+
does not exist (the beta-channel transition window). Any OTHER failure
|
|
1071
|
+
(5xx, 429, timeout, network, malformed JSON, missing `version`) raises
|
|
1072
|
+
the matching ``UpdateCheck*`` exception so the caller preserves the
|
|
1073
|
+
prior cached `latest_version` (last-known-good).
|
|
1074
|
+
|
|
1075
|
+
Endpoints: `latest` → :data:`UPDATE_NPM_REGISTRY_URL`; any other tag →
|
|
1076
|
+
:data:`UPDATE_NPM_BETA_REGISTRY_URL`. The per-tag env hook
|
|
1077
|
+
``CCTALLY_TEST_UPDATE_NPM_<TAG>_STATUS`` (e.g. ``..._BETA_STATUS=404``)
|
|
1078
|
+
simulates a bare HTTP status for the golden harness, which serves
|
|
1079
|
+
`file://` fixtures and therefore cannot emit a genuine 404/5xx —
|
|
1080
|
+
mirrors the ``CCTALLY_TEST_UPDATE_NPM_URL`` fixture-URL hook.
|
|
1081
|
+
"""
|
|
1082
|
+
c = _cctally()
|
|
1083
|
+
forced = os.environ.get(f"CCTALLY_TEST_UPDATE_NPM_{tag.upper()}_STATUS")
|
|
1084
|
+
if forced:
|
|
1085
|
+
try:
|
|
1086
|
+
code = int(forced.strip())
|
|
1087
|
+
except ValueError:
|
|
1088
|
+
code = 500
|
|
1089
|
+
if code == 404:
|
|
1090
|
+
return None
|
|
1091
|
+
if code == 429:
|
|
1092
|
+
raise UpdateCheckRateLimited(f"HTTP 429 (forced): dist-tag {tag}")
|
|
1093
|
+
raise UpdateCheckHTTPError(
|
|
1094
|
+
f"HTTP {code} (forced): dist-tag {tag}", status_code=code
|
|
1095
|
+
)
|
|
1096
|
+
url = (
|
|
1097
|
+
c.UPDATE_NPM_REGISTRY_URL if tag == "latest"
|
|
1098
|
+
else c.UPDATE_NPM_BETA_REGISTRY_URL
|
|
1099
|
+
)
|
|
1100
|
+
try:
|
|
1101
|
+
status, body = c._fetch_url(url)
|
|
1102
|
+
except UpdateCheckHTTPError as e:
|
|
1103
|
+
if getattr(e, "status_code", None) == 404:
|
|
1104
|
+
return None
|
|
1105
|
+
raise
|
|
1106
|
+
try:
|
|
1107
|
+
data = json.loads(body.decode("utf-8"))
|
|
1108
|
+
return data["version"]
|
|
1109
|
+
except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
|
|
1110
|
+
raise UpdateCheckParseError(f"npm dist-tag {tag} parse failed: {e}")
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _resolve_npm_channel_target(channel: str) -> "tuple[str, str]":
|
|
1114
|
+
"""Resolve the npm install target for ``channel`` → ``(version, dist_tag)``.
|
|
1115
|
+
|
|
1116
|
+
Stable: the `latest` dist-tag verbatim (byte-identical to today).
|
|
1117
|
+
Beta: **SemVer-max(dist-tags.beta, dist-tags.latest)** — an absent
|
|
1118
|
+
`beta` tag (exact 404) falls back to `latest` (transition window);
|
|
1119
|
+
any other beta-leg failure propagates and fails the whole resolution.
|
|
1120
|
+
Both legs are SemVer-validated before the max so a malformed/non-SemVer
|
|
1121
|
+
payload raises :class:`UpdateCheckParseError` (preserving last-known-
|
|
1122
|
+
good), never a silent bad target. ``dist_tag`` is which tag actually
|
|
1123
|
+
produced the returned version (`beta` only when beta strictly wins).
|
|
1124
|
+
"""
|
|
1125
|
+
c = _cctally()
|
|
1126
|
+
latest = c._fetch_npm_dist_tag_version("latest")
|
|
1127
|
+
if latest is None:
|
|
1128
|
+
# `latest` should always exist; a 404 here is a real failure.
|
|
1129
|
+
raise UpdateCheckHTTPError(
|
|
1130
|
+
"npm dist-tag latest returned 404", status_code=404
|
|
1131
|
+
)
|
|
1132
|
+
c._semver_or_parse_error(latest)
|
|
1133
|
+
if channel != "beta":
|
|
1134
|
+
return (latest, "latest")
|
|
1135
|
+
beta = c._fetch_npm_dist_tag_version("beta")
|
|
1136
|
+
if beta is None:
|
|
1137
|
+
# Transition window: beta dist-tag not yet published → track latest.
|
|
1138
|
+
return (latest, "latest")
|
|
1139
|
+
c._semver_or_parse_error(beta)
|
|
1140
|
+
if c._semver_gt(beta, latest):
|
|
1141
|
+
return (beta, "beta")
|
|
1142
|
+
return (latest, "latest")
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _semver_or_parse_error(version: str) -> str:
|
|
1146
|
+
"""Validate ``version`` as SemVer, mapping a parse failure to
|
|
1147
|
+
:class:`UpdateCheckParseError` so it flows through the check
|
|
1148
|
+
pipeline's last-known-good preservation instead of escaping as a bare
|
|
1149
|
+
``ValueError``."""
|
|
1150
|
+
try:
|
|
1151
|
+
_release_parse_semver(version)
|
|
1152
|
+
except ValueError:
|
|
1153
|
+
raise UpdateCheckParseError(f"non-SemVer version from npm: {version!r}")
|
|
1154
|
+
return version
|
|
1155
|
+
|
|
1156
|
+
|
|
1004
1157
|
def _check_brew_latest_version() -> str:
|
|
1005
1158
|
"""Fetch the brew formula raw blob and extract the version.
|
|
1006
1159
|
|
|
@@ -1035,6 +1188,25 @@ def _is_update_check_due(config: dict) -> bool:
|
|
|
1035
1188
|
enabled = check_cfg.get("enabled", True)
|
|
1036
1189
|
if not enabled:
|
|
1037
1190
|
return False
|
|
1191
|
+
# Channel-flip invalidation (beta-channel, spec 2026-07-21 §3): a
|
|
1192
|
+
# configured channel that differs from the last-attempted channel means
|
|
1193
|
+
# the cached `latest_version` was resolved for the wrong channel — treat
|
|
1194
|
+
# the TTL as expired and refresh immediately. `last_attempt_channel` is
|
|
1195
|
+
# stamped from config BEFORE the marker touch in `_do_update_check`, so
|
|
1196
|
+
# after one refresh it matches config again — no retry storm. Absent
|
|
1197
|
+
# field defaults to "stable" (back-compat with pre-feature state files).
|
|
1198
|
+
try:
|
|
1199
|
+
configured_channel = c.resolve_update_channel(config)
|
|
1200
|
+
prior_state = c._load_update_state()
|
|
1201
|
+
last_attempt = (prior_state or {}).get(
|
|
1202
|
+
"last_attempt_channel", "stable"
|
|
1203
|
+
)
|
|
1204
|
+
if configured_channel != last_attempt:
|
|
1205
|
+
return True
|
|
1206
|
+
except Exception:
|
|
1207
|
+
# Never let a state-read hiccup turn the TTL gate into a hard error;
|
|
1208
|
+
# fall through to the marker check.
|
|
1209
|
+
pass
|
|
1038
1210
|
ttl_hours = check_cfg.get("ttl_hours", c.UPDATE_DEFAULT_TTL_HOURS)
|
|
1039
1211
|
try:
|
|
1040
1212
|
mtime = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.stat().st_mtime
|
|
@@ -1056,8 +1228,30 @@ def _do_update_check() -> None:
|
|
|
1056
1228
|
typed exception to a `check_status` enum (`rate_limited` /
|
|
1057
1229
|
`fetch_failed` / `parse_failed`); never lose the prior
|
|
1058
1230
|
`latest_version`. State is saved unconditionally on the way out.
|
|
1231
|
+
|
|
1232
|
+
Channel-aware (beta-channel, spec 2026-07-21 §3): reads the configured
|
|
1233
|
+
release channel and stamps ``last_attempt_channel`` from config BEFORE
|
|
1234
|
+
the marker touch + fetch (so a failed channel-switch fetch neither
|
|
1235
|
+
retry-storms — marker-first holds — nor mis-attributes stale data). On
|
|
1236
|
+
the npm vector, ``channel="beta"`` resolves SemVer-max(beta, latest);
|
|
1237
|
+
on a successful complete resolution ``latest_version_channel`` +
|
|
1238
|
+
``resolved_dist_tag`` record what produced ``latest_version``. Brew is
|
|
1239
|
+
always stable (Q2). The stable-npm path is byte-identical to before.
|
|
1059
1240
|
"""
|
|
1060
1241
|
c = _cctally()
|
|
1242
|
+
config = load_config()
|
|
1243
|
+
channel = c.resolve_update_channel(config)
|
|
1244
|
+
|
|
1245
|
+
# Stamp `last_attempt_channel` from config and PERSIST it BEFORE the
|
|
1246
|
+
# marker touch + network fetch. Even if the fetch then crashes, the
|
|
1247
|
+
# attempted channel is on disk, so `_is_update_check_due`'s channel-flip
|
|
1248
|
+
# bypass sees config==last_attempt and defers to the (now-touched)
|
|
1249
|
+
# marker — no retry storm; `latest_version_channel` (written only on
|
|
1250
|
+
# success) still names the OLD producer, so no mis-attribution.
|
|
1251
|
+
state = c._load_update_state() or {"_schema": 1}
|
|
1252
|
+
state["last_attempt_channel"] = channel
|
|
1253
|
+
c._save_update_state(state)
|
|
1254
|
+
|
|
1061
1255
|
# Touch marker FIRST — crash safety: a dead process mid-fetch must
|
|
1062
1256
|
# not trigger another fetch within the TTL window.
|
|
1063
1257
|
_cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -1066,6 +1260,9 @@ def _do_update_check() -> None:
|
|
|
1066
1260
|
method = c._detect_install_method(mutate=True)
|
|
1067
1261
|
|
|
1068
1262
|
state = c._load_update_state() or {"_schema": 1}
|
|
1263
|
+
# Belt-and-suspenders: detection preserves other fields, but keep the
|
|
1264
|
+
# attempted channel authoritative on the object we save at the end.
|
|
1265
|
+
state["last_attempt_channel"] = channel
|
|
1069
1266
|
cur = _release_read_latest_release_version()
|
|
1070
1267
|
if cur:
|
|
1071
1268
|
state["current_version"] = cur[0]
|
|
@@ -1077,12 +1274,25 @@ def _do_update_check() -> None:
|
|
|
1077
1274
|
|
|
1078
1275
|
try:
|
|
1079
1276
|
if method.method == "npm":
|
|
1080
|
-
|
|
1081
|
-
|
|
1277
|
+
if channel == "beta":
|
|
1278
|
+
latest, dist_tag = c._resolve_npm_channel_target("beta")
|
|
1279
|
+
state["latest_version"] = latest
|
|
1280
|
+
state["latest_version_channel"] = "beta"
|
|
1281
|
+
state["resolved_dist_tag"] = dist_tag
|
|
1282
|
+
else:
|
|
1283
|
+
latest = c._check_npm_latest_version()
|
|
1284
|
+
state["latest_version"] = latest
|
|
1285
|
+
state["latest_version_channel"] = "stable"
|
|
1286
|
+
state["resolved_dist_tag"] = "latest"
|
|
1082
1287
|
state["source"] = "npm-registry"
|
|
1083
1288
|
elif method.method == "brew":
|
|
1289
|
+
# Brew tracks stable only (Q2) — no dist-tag concept. The
|
|
1290
|
+
# beta+brew advisory rides on the CLI --check / doctor surfaces,
|
|
1291
|
+
# not here.
|
|
1084
1292
|
latest = c._check_brew_latest_version()
|
|
1085
1293
|
state["latest_version"] = latest
|
|
1294
|
+
state["latest_version_channel"] = "stable"
|
|
1295
|
+
state["resolved_dist_tag"] = None
|
|
1086
1296
|
state["source"] = "github-formula"
|
|
1087
1297
|
else:
|
|
1088
1298
|
# Unknown install method — no remote check possible
|
|
@@ -1233,6 +1443,48 @@ def _format_update_command(method: str, version: str | None) -> str:
|
|
|
1233
1443
|
return ""
|
|
1234
1444
|
|
|
1235
1445
|
|
|
1446
|
+
# Advisory shown on the beta channel for a brew install: brew IS the stable
|
|
1447
|
+
# channel (Q2), so a beta opt-in on brew resolves stable + this line.
|
|
1448
|
+
BREW_BETA_ADVISORY = (
|
|
1449
|
+
"beta channel unavailable for brew installs — use npm or source"
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def _resolved_install_target(state: dict, channel: str) -> "str | None":
|
|
1454
|
+
"""The exact version a bare (unpinned) beta-channel npm install targets.
|
|
1455
|
+
|
|
1456
|
+
On the beta channel, npm installs the EXACT resolved version
|
|
1457
|
+
(`npm install -g cctally@X.Y.Z`), never bare `@beta` — whose registry
|
|
1458
|
+
target can disagree with the advertised SemVer-max(beta, latest) and
|
|
1459
|
+
which doesn't exist in the transition window. Returns the cached
|
|
1460
|
+
`latest_version` (the resolved max, written by the check pipeline) for
|
|
1461
|
+
an npm beta channel, else ``None`` (stable → `@latest`; brew has no
|
|
1462
|
+
versioned formulae). Callers pass the explicit `--version` pin ahead of
|
|
1463
|
+
this — the pin always wins.
|
|
1464
|
+
"""
|
|
1465
|
+
method = (state.get("install") or {}).get("method", "unknown")
|
|
1466
|
+
if method == "npm" and channel == "beta":
|
|
1467
|
+
return state.get("latest_version")
|
|
1468
|
+
return None
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def _resolved_update_command(state: dict, config: "dict | None") -> str:
|
|
1472
|
+
"""Channel-aware install command for the `--check` renderers / envelope.
|
|
1473
|
+
|
|
1474
|
+
Stable and brew are byte-identical to `_format_update_command(method,
|
|
1475
|
+
None)`. On an npm beta channel it pins the exact resolved version
|
|
1476
|
+
(`cctally@X.Y.Z`) so every surface — CLI `--check`, `--json`, dashboard
|
|
1477
|
+
badge/modal — advertises the same target and command.
|
|
1478
|
+
"""
|
|
1479
|
+
c = _cctally()
|
|
1480
|
+
method = (state.get("install") or {}).get("method", "unknown")
|
|
1481
|
+
channel = c.resolve_update_channel(config or {})
|
|
1482
|
+
target = _resolved_install_target(state, channel)
|
|
1483
|
+
if target:
|
|
1484
|
+
return c._format_update_command(method, target)
|
|
1485
|
+
return c._format_update_command(method, None)
|
|
1486
|
+
|
|
1487
|
+
|
|
1236
1488
|
def _prerelease_note(current: str) -> str | None:
|
|
1237
1489
|
"""Spec §1.8 — prerelease users get a one-shot informational note in
|
|
1238
1490
|
`--check` output. Returns the canned two-line message verbatim per
|
|
@@ -1247,9 +1499,15 @@ def _prerelease_note(current: str) -> str | None:
|
|
|
1247
1499
|
|
|
1248
1500
|
|
|
1249
1501
|
def _format_update_check_json(
|
|
1250
|
-
state: dict[str, Any], suppress: dict[str, Any]
|
|
1502
|
+
state: dict[str, Any], suppress: dict[str, Any],
|
|
1503
|
+
config: "dict | None" = None,
|
|
1251
1504
|
) -> dict[str, Any]:
|
|
1252
|
-
"""JSON shape for `cctally update --check --json` (spec §4.4).
|
|
1505
|
+
"""JSON shape for `cctally update --check --json` (spec §4.4).
|
|
1506
|
+
|
|
1507
|
+
``config`` is optional and defaults to stable — passing ``None`` keeps
|
|
1508
|
+
the stable envelope byte-identical to before. On an npm beta channel,
|
|
1509
|
+
``update_command`` pins the exact resolved version (spec 2026-07-21 §3).
|
|
1510
|
+
"""
|
|
1253
1511
|
c = _cctally()
|
|
1254
1512
|
cur = state.get("current_version")
|
|
1255
1513
|
lat = state.get("latest_version")
|
|
@@ -1281,7 +1539,7 @@ def _format_update_check_json(
|
|
|
1281
1539
|
"latest_version": lat,
|
|
1282
1540
|
"available": available,
|
|
1283
1541
|
"method": method,
|
|
1284
|
-
"update_command": c.
|
|
1542
|
+
"update_command": c._resolved_update_command(state, config),
|
|
1285
1543
|
"release_notes_url": state.get("latest_version_url"),
|
|
1286
1544
|
"check_status": state.get("check_status"),
|
|
1287
1545
|
"check_error": state.get("check_error"),
|
|
@@ -1304,7 +1562,8 @@ _UPDATE_METHOD_HUMAN_LABEL = {
|
|
|
1304
1562
|
|
|
1305
1563
|
|
|
1306
1564
|
def _format_update_check_human(
|
|
1307
|
-
state: dict[str, Any], suppress: dict[str, Any]
|
|
1565
|
+
state: dict[str, Any], suppress: dict[str, Any],
|
|
1566
|
+
config: "dict | None" = None,
|
|
1308
1567
|
) -> str:
|
|
1309
1568
|
"""Multi-line plaintext block for `cctally update --check` (spec §4.4).
|
|
1310
1569
|
|
|
@@ -1312,8 +1571,14 @@ def _format_update_check_human(
|
|
|
1312
1571
|
(`Will run` is the longest at 8 chars + 2-space gutter). Method row
|
|
1313
1572
|
appends ` (auto-detected)` per spec example. Up-to-date / unknown
|
|
1314
1573
|
variants append a fallback line below the table.
|
|
1574
|
+
|
|
1575
|
+
``config`` is optional and defaults to stable — passing ``None`` keeps
|
|
1576
|
+
the stable output byte-identical. On an npm beta channel the `Will run`
|
|
1577
|
+
line pins the exact resolved version; a beta opt-in on brew appends the
|
|
1578
|
+
``BREW_BETA_ADVISORY`` line (brew tracks stable only, Q2).
|
|
1315
1579
|
"""
|
|
1316
1580
|
c = _cctally()
|
|
1581
|
+
channel = c.resolve_update_channel(config or {})
|
|
1317
1582
|
cur = state.get("current_version") or "unknown"
|
|
1318
1583
|
lat = state.get("latest_version") or "unknown"
|
|
1319
1584
|
method = (state.get("install") or {}).get("method", "unknown")
|
|
@@ -1334,7 +1599,7 @@ def _format_update_check_human(
|
|
|
1334
1599
|
f"{'Latest':<10}{lat}",
|
|
1335
1600
|
f"{'Method':<10}{method_label} (auto-detected)",
|
|
1336
1601
|
]
|
|
1337
|
-
will_run = c.
|
|
1602
|
+
will_run = c._resolved_update_command(state, config)
|
|
1338
1603
|
if will_run:
|
|
1339
1604
|
lines.append(f"{'Will run':<10}{will_run}")
|
|
1340
1605
|
if url:
|
|
@@ -1342,6 +1607,10 @@ def _format_update_check_human(
|
|
|
1342
1607
|
if status and status != "ok":
|
|
1343
1608
|
status_value = status + (f" ({err})" if err else "")
|
|
1344
1609
|
lines.append(f"{'Status':<10}{status_value}")
|
|
1610
|
+
if channel == "beta" and method == "brew":
|
|
1611
|
+
# Brew IS the stable channel (Q2). Surface the advisory so a beta
|
|
1612
|
+
# opt-in on brew explains why it resolves stable.
|
|
1613
|
+
lines.append(f"{'Channel':<10}{c.BREW_BETA_ADVISORY}")
|
|
1345
1614
|
lines.append("")
|
|
1346
1615
|
if method == "unknown":
|
|
1347
1616
|
# No remote check is possible for source / dev installs; render
|
|
@@ -1486,9 +1755,11 @@ def _do_update_check_user(*, force: bool, output_json: bool) -> int:
|
|
|
1486
1755
|
return 0
|
|
1487
1756
|
suppress = c._load_update_suppress()
|
|
1488
1757
|
if output_json:
|
|
1489
|
-
print(json.dumps(
|
|
1758
|
+
print(json.dumps(
|
|
1759
|
+
c._format_update_check_json(state, suppress, config), indent=2
|
|
1760
|
+
))
|
|
1490
1761
|
else:
|
|
1491
|
-
print(c._format_update_check_human(state, suppress))
|
|
1762
|
+
print(c._format_update_check_human(state, suppress, config))
|
|
1492
1763
|
return 0
|
|
1493
1764
|
|
|
1494
1765
|
|
|
@@ -1649,11 +1920,54 @@ def _do_update_install(
|
|
|
1649
1920
|
/ write-perm-denied; :class:`UpdateValidationError` (rc=2) for
|
|
1650
1921
|
invalid --version / --version+brew. The boundary distinction is
|
|
1651
1922
|
enforced by :func:`cmd_update`'s try/except below.
|
|
1923
|
+
|
|
1924
|
+
Channel-aware (beta-channel, spec 2026-07-21 §3): a bare (unpinned)
|
|
1925
|
+
npm install on the beta channel targets the EXACT resolved version
|
|
1926
|
+
(`cctally@X.Y.Z`), never bare `@beta`; a bare install (either channel)
|
|
1927
|
+
refuses as a no-op (exit 0) when the resolved target is not SemVer-newer
|
|
1928
|
+
than the installed version — this makes a beta→stable flip-back safe
|
|
1929
|
+
(no silent downgrade to `@latest`). An explicit `--version` pin always
|
|
1930
|
+
overrides both. Brew tracks stable only (Q2): a beta opt-in on brew
|
|
1931
|
+
prints the advisory and proceeds as a stable upgrade.
|
|
1652
1932
|
"""
|
|
1653
1933
|
c = _cctally()
|
|
1654
1934
|
method = c._detect_install_method(mutate=not dry_run)
|
|
1655
|
-
|
|
1656
|
-
|
|
1935
|
+
config = load_config()
|
|
1936
|
+
channel = c.resolve_update_channel(config)
|
|
1937
|
+
state = c._load_update_state() or {}
|
|
1938
|
+
|
|
1939
|
+
# Beta resolves the exact target from the cached max(beta, latest); an
|
|
1940
|
+
# explicit --version pin always wins (the deliberate override).
|
|
1941
|
+
resolved_version = version
|
|
1942
|
+
if resolved_version is None:
|
|
1943
|
+
resolved_version = c._resolved_install_target(state, channel)
|
|
1944
|
+
|
|
1945
|
+
# Downgrade refusal for a BARE (unpinned) install: no-op + exit 0 when
|
|
1946
|
+
# the resolved target is not SemVer-newer than the installed version.
|
|
1947
|
+
if version is None and method.method in ("npm", "brew"):
|
|
1948
|
+
target = resolved_version or state.get("latest_version")
|
|
1949
|
+
current = state.get("current_version")
|
|
1950
|
+
if target and current:
|
|
1951
|
+
try:
|
|
1952
|
+
newer = c._semver_gt(target, current)
|
|
1953
|
+
except ValueError:
|
|
1954
|
+
newer = True # can't compare → don't block a real upgrade
|
|
1955
|
+
if not newer:
|
|
1956
|
+
print(
|
|
1957
|
+
f"cctally is already up to date on the {channel} channel "
|
|
1958
|
+
f"(installed {current}, latest {target}). Nothing to do.\n"
|
|
1959
|
+
"To install a specific version anyway, run "
|
|
1960
|
+
"`cctally update --version X.Y.Z`."
|
|
1961
|
+
)
|
|
1962
|
+
return 0
|
|
1963
|
+
|
|
1964
|
+
# Brew tracks stable only (Q2): surface the advisory, then proceed as a
|
|
1965
|
+
# normal (stable) brew upgrade.
|
|
1966
|
+
if channel == "beta" and method.method == "brew":
|
|
1967
|
+
print(f"Note: {c.BREW_BETA_ADVISORY}.")
|
|
1968
|
+
|
|
1969
|
+
c._preflight_install(method, resolved_version)
|
|
1970
|
+
steps = c._build_update_steps(method, resolved_version)
|
|
1657
1971
|
if dry_run:
|
|
1658
1972
|
for name, cmd in steps:
|
|
1659
1973
|
if output_json:
|
|
@@ -1679,7 +1993,9 @@ def _do_update_install(
|
|
|
1679
1993
|
if rc != 0:
|
|
1680
1994
|
return 1
|
|
1681
1995
|
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
1682
|
-
|
|
1996
|
+
# Stamp the EXACT installed version (the beta-resolved target
|
|
1997
|
+
# when unpinned on beta; else the pin / fresh-CHANGELOG fallback).
|
|
1998
|
+
c._stamp_install_success_to_state(resolved_version, method)
|
|
1683
1999
|
return 0
|
|
1684
2000
|
finally:
|
|
1685
2001
|
c._release_update_lock(lock_fd)
|
|
@@ -2234,10 +2550,17 @@ def _format_update_banner(state: dict[str, Any]) -> str:
|
|
|
2234
2550
|
|
|
2235
2551
|
Includes the dismissal recipe inline so the user never has to
|
|
2236
2552
|
consult docs to silence it.
|
|
2553
|
+
|
|
2554
|
+
Channel marker (beta-channel, spec 2026-07-21 §3): a ``(beta)`` marker
|
|
2555
|
+
lands after the version when the last-attempted channel was beta —
|
|
2556
|
+
``↑ cctally 1.77.0 (beta) available…``. Gating stays in
|
|
2557
|
+
``_should_show_update_banner`` (unchanged); this formatter only adds the
|
|
2558
|
+
marker, so the stable banner is byte-identical.
|
|
2237
2559
|
"""
|
|
2238
2560
|
cur = state["current_version"]
|
|
2239
2561
|
lat = state["latest_version"]
|
|
2562
|
+
marker = " (beta)" if state.get("last_attempt_channel") == "beta" else ""
|
|
2240
2563
|
return (
|
|
2241
|
-
f"↑ cctally {lat} available (you're on {cur}). "
|
|
2564
|
+
f"↑ cctally {lat}{marker} available (you're on {cur}). "
|
|
2242
2565
|
f"Run `cctally update`. Skip: cctally update --skip {lat}"
|
|
2243
2566
|
)
|
package/bin/_cctally_weekrefs.py
CHANGED
|
@@ -72,7 +72,11 @@ def _get_canonical_boundary_for_date(
|
|
|
72
72
|
return None, None
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
def get_recent_weeks(conn: sqlite3.Connection, limit: int) -> list[WeekRef]:
|
|
75
|
+
def get_recent_weeks(conn: sqlite3.Connection, limit: "int | None") -> list[WeekRef]:
|
|
76
|
+
# ``limit=None`` → SQL ``LIMIT -1`` (unbounded): the milestone-history
|
|
77
|
+
# index (#hero-milestone-history) enumerates EVERY navigable week with no
|
|
78
|
+
# depth cap. Existing callers pass a concrete int and are unaffected.
|
|
79
|
+
limit_sql = -1 if limit is None else int(limit)
|
|
76
80
|
rows = conn.execute(
|
|
77
81
|
"""
|
|
78
82
|
SELECT week_start_date, MAX(week_end_date) AS week_end_date
|
|
@@ -85,7 +89,7 @@ def get_recent_weeks(conn: sqlite3.Connection, limit: int) -> list[WeekRef]:
|
|
|
85
89
|
ORDER BY week_start_date DESC
|
|
86
90
|
LIMIT ?
|
|
87
91
|
""",
|
|
88
|
-
(
|
|
92
|
+
(limit_sql,),
|
|
89
93
|
).fetchall()
|
|
90
94
|
|
|
91
95
|
refs: list[WeekRef] = []
|