cctally 1.4.0 → 1.5.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/bin/cctally CHANGED
@@ -30,9 +30,11 @@ import dataclasses
30
30
  import datetime as dt
31
31
  import fcntl
32
32
  import hashlib
33
+ import io
33
34
  import json
34
35
  import os
35
36
  import pathlib
37
+ import queue
36
38
  import re
37
39
  import secrets
38
40
  import math
@@ -42,6 +44,7 @@ import sqlite3
42
44
  import subprocess
43
45
  import tempfile
44
46
  import textwrap
47
+ import threading
45
48
  import time
46
49
  import traceback
47
50
  import urllib.error
@@ -76,6 +79,7 @@ SETUP_SYMLINK_NAMES = (
76
79
  "cctally-release",
77
80
  "cctally-sync-week",
78
81
  "cctally-tui",
82
+ "cctally-update",
79
83
  )
80
84
 
81
85
  # Hook events we register entries for (Section 1 of spec).
@@ -216,6 +220,43 @@ def _release_read_latest_release_version() -> tuple[str, str] | None:
216
220
  return (m.group(1), m.group(2))
217
221
 
218
222
 
223
+ _FORMULA_VERSION_RE = re.compile(
224
+ rf'/v({_SEMVER_NUM}\.{_SEMVER_NUM}\.{_SEMVER_NUM}'
225
+ rf'(?:-[a-zA-Z][a-zA-Z0-9-]*\.{_SEMVER_NUM})?)\.tar\.gz'
226
+ )
227
+
228
+
229
+ def _release_extract_formula_version(text: str) -> str | None:
230
+ """Extract the SemVer string from a homebrew formula's archive URL.
231
+
232
+ Returns the matched version (e.g. `"1.3.0"`, `"1.0.0-rc.1"`) or
233
+ ``None`` if no `/vX.Y.Z[.tar.gz]` substring is found. Used by Phase 6's
234
+ monotonic-version gate (issue #30) — the gate refuses to write a lower
235
+ version on top of a higher one. A formula that does not match is
236
+ treated as unversioned (gate allows the write).
237
+ """
238
+ m = _FORMULA_VERSION_RE.search(text)
239
+ return m.group(1) if m else None
240
+
241
+
242
+ def _release_semver_sort_key(
243
+ parsed: tuple[int, int, int, str | None, int | None],
244
+ ) -> tuple:
245
+ """Total-order sort key for `_release_parse_semver` output.
246
+
247
+ SemVer §11.4: a stable release has higher precedence than a pre-release
248
+ of the same MAJOR.MINOR.PATCH. Naive tuple comparison breaks because
249
+ Python rejects ``None < str`` at runtime. The key returned here makes
250
+ stable releases sort *after* their pre-releases by inverting the
251
+ "has-prerelease" axis: stable → ``(maj, min, pat, 1, "", 0)``,
252
+ pre-release → ``(maj, min, pat, 0, id, n)``.
253
+ """
254
+ maj, min_, pat, pre_id, pre_n = parsed
255
+ if pre_id is None:
256
+ return (maj, min_, pat, 1, "", 0)
257
+ return (maj, min_, pat, 0, pre_id, pre_n)
258
+
259
+
219
260
  def _release_brew_archive_url(version: str) -> str:
220
261
  """Return the GitHub auto-archive URL for a version tag.
221
262
 
@@ -1537,6 +1578,7 @@ def _release_run_phase_npm(
1537
1578
  def _release_run_phase_brew(
1538
1579
  version: str,
1539
1580
  brew_clone: pathlib.Path | None,
1581
+ allow_downgrade: bool = False,
1540
1582
  ) -> int:
1541
1583
  """Phase 6 — render ``Formula/cctally.rb`` and push to the brew tap.
1542
1584
 
@@ -1551,6 +1593,13 @@ def _release_run_phase_brew(
1551
1593
  version (idempotency under ``--resume``).
1552
1594
  - Dirty working tree — refuses with exit 2 and points the operator
1553
1595
  at ``--resume``.
1596
+ - **Monotonic-version gate (issue #30).** Refuses with exit 2 when
1597
+ the existing on-disk formula's URL pins a *higher* SemVer than
1598
+ ``version``. Catches the f02b2f1 regression class — operator
1599
+ runs Phase 6 from a stale CHANGELOG / fixture leak / accidental
1600
+ old branch and would otherwise silently write a lower version
1601
+ over a higher one. Override via ``allow_downgrade=True``
1602
+ (operator-driven, for genuine yank/revert cases).
1554
1603
  - Push failure — auth-fallback parity with Phase 4: prints the
1555
1604
  exact recovery command and returns 0. Phases 1-5 already
1556
1605
  succeeded, the release IS published from the user's
@@ -1560,7 +1609,7 @@ def _release_run_phase_brew(
1560
1609
  Returns:
1561
1610
  - ``0`` on success, graceful skip, idempotent short-circuit, OR
1562
1611
  push fallback.
1563
- - ``2`` on dirty-working-tree refusal.
1612
+ - ``2`` on dirty-working-tree refusal OR downgrade-gate refusal.
1564
1613
  """
1565
1614
  print("phase 6: brew formula bump")
1566
1615
  if brew_clone is None:
@@ -1610,6 +1659,57 @@ def _release_run_phase_brew(
1610
1659
  f" local formula already at v{version}; re-pushing to tap…"
1611
1660
  )
1612
1661
  else:
1662
+ # Monotonic-version gate (issue #30). The brew tap regressed
1663
+ # from v1.3.0 → v1.0.0 twice in one day via this code path; the
1664
+ # equality fingerprint above (`local_at_version`) is False on a
1665
+ # downgrade, so without this gate we'd silently overwrite a
1666
+ # higher version. Compare the existing formula's URL-pinned
1667
+ # SemVer against `version` (SemVer-aware so prereleases sort
1668
+ # below their stable counterpart per §11.4); refuse with exit 2
1669
+ # when the on-disk version is strictly higher. Unparseable
1670
+ # formulas are treated as unversioned and allowed through.
1671
+ if formula_path.exists():
1672
+ existing_text = formula_path.read_text(encoding="utf-8")
1673
+ existing_v = _release_extract_formula_version(existing_text)
1674
+ if existing_v is not None:
1675
+ try:
1676
+ existing_key = _release_semver_sort_key(
1677
+ _release_parse_semver(existing_v)
1678
+ )
1679
+ target_key = _release_semver_sort_key(
1680
+ _release_parse_semver(version)
1681
+ )
1682
+ except ValueError:
1683
+ existing_key = target_key = None
1684
+ if (
1685
+ existing_key is not None
1686
+ and target_key is not None
1687
+ and existing_key > target_key
1688
+ and not allow_downgrade
1689
+ ):
1690
+ print(
1691
+ f" refuse: existing formula pins v{existing_v}, "
1692
+ f"target is v{version} (downgrade).\n"
1693
+ " Common causes: stale CHANGELOG.md in this clone, "
1694
+ "fixture leak into a real `release.brewClone`, or an "
1695
+ "accidental old branch.\n"
1696
+ " Verify intent, then re-run with "
1697
+ "`--allow-formula-downgrade` to override "
1698
+ "(yank / revert cases). See issue #30.",
1699
+ file=sys.stderr,
1700
+ )
1701
+ return 2
1702
+ if (
1703
+ existing_key is not None
1704
+ and target_key is not None
1705
+ and existing_key > target_key
1706
+ and allow_downgrade
1707
+ ):
1708
+ print(
1709
+ f" WARNING: writing v{version} over existing v{existing_v} "
1710
+ "(--allow-formula-downgrade); intentional yank?",
1711
+ file=sys.stderr,
1712
+ )
1613
1713
  print(f" computing sha256 of v{version} archive…")
1614
1714
  sha = _release_compute_brew_sha256(version)
1615
1715
 
@@ -1920,7 +2020,11 @@ def cmd_release(args: argparse.Namespace) -> int:
1920
2020
  print(f"phase 6: brew skipped{suffix}")
1921
2021
  else:
1922
2022
  brew_clone = _release_discover_brew_clone(args)
1923
- rc = _release_run_phase_brew(next_v, brew_clone)
2023
+ rc = _release_run_phase_brew(
2024
+ next_v,
2025
+ brew_clone,
2026
+ allow_downgrade=args.allow_formula_downgrade,
2027
+ )
1924
2028
  if rc != 0:
1925
2029
  return rc
1926
2030
 
@@ -2069,6 +2173,36 @@ CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
2069
2173
  LOG_DIR = APP_DIR / "logs"
2070
2174
  MIGRATION_ERROR_LOG_PATH = LOG_DIR / "migration-errors.log"
2071
2175
 
2176
+ # === Update subcommand (Section 1 of update-subcommand spec) ===
2177
+ # Path constants for the `cctally update` feature. Centralised here
2178
+ # (alongside other APP_DIR-derived paths) so the test fixture loader in
2179
+ # tests/conftest.py:redirect_paths can monkeypatch them, and so later
2180
+ # tasks (install detection, version-check pipeline, dashboard worker)
2181
+ # don't have to revisit constant placement.
2182
+ UPDATE_STATE_PATH = APP_DIR / "update-state.json"
2183
+ UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
2184
+ UPDATE_LOCK_PATH = APP_DIR / "update.lock"
2185
+ UPDATE_LOG_PATH = APP_DIR / "update.log"
2186
+ UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
2187
+ UPDATE_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB; spec §1.5
2188
+ UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
2189
+
2190
+ UPDATE_NPM_REGISTRY_URL = os.environ.get(
2191
+ "CCTALLY_TEST_UPDATE_NPM_URL",
2192
+ "https://registry.npmjs.org/cctally/latest",
2193
+ )
2194
+ UPDATE_BREW_FORMULA_URL = os.environ.get(
2195
+ "CCTALLY_TEST_UPDATE_BREW_URL",
2196
+ "https://raw.githubusercontent.com/omrikais/homebrew-cctally/main/Formula/cctally.rb",
2197
+ )
2198
+
2199
+ UPDATE_DEFAULT_TTL_HOURS = 24
2200
+ UPDATE_MAX_TTL_HOURS = 720 # 30 days
2201
+ UPDATE_NETWORK_TIMEOUT_S = 5.0
2202
+ UPDATE_NPM_PREFIX_TIMEOUT_S = 2.0
2203
+ UPDATE_NPM_PREFIX_TTL_DAYS = 7
2204
+ UPDATE_DASHBOARD_CHECK_POLL_S = 30 * 60
2205
+
2072
2206
 
2073
2207
  def _migrate_legacy_data_dir() -> None:
2074
2208
  """One-shot: ~/.local/share/ccusage-subscription → ~/.local/share/cctally.
@@ -8698,6 +8832,75 @@ class RefreshUsageMalformedError(Exception):
8698
8832
  required seven_day fields (utilization or resets_at)."""
8699
8833
 
8700
8834
 
8835
+ # === `cctally update` exception cluster (spec §1, §3, §5) =================
8836
+ # These live near RefreshUsage* because the version-check pipeline (Task 3)
8837
+ # is structurally analogous: a single networked refresh that can rate-limit,
8838
+ # fail to fetch, or return unparseable bodies. The base class is caught at
8839
+ # the command boundary in cmd_update_* (Task 4); subtypes let JSON-mode
8840
+ # emit a `check_status` enum value (rate_limited / fetch_failed / etc.) per
8841
+ # spec §1.2.
8842
+
8843
+
8844
+ class UpdateError(Exception):
8845
+ """User-facing error from the update subcommand. Caught at command boundary.
8846
+
8847
+ Default rc when caught at the boundary is 1 (runtime / environment
8848
+ failure: unknown install method, npm prefix not writable, etc.).
8849
+ Validation errors (invalid --version syntax, --version+brew combo)
8850
+ use the :class:`UpdateValidationError` subclass so the boundary can
8851
+ map them to rc=2 — preserving the rc=1-vs-rc=2 distinction the
8852
+ Task-4 inline gates exposed.
8853
+ """
8854
+
8855
+
8856
+ class UpdateValidationError(UpdateError):
8857
+ """Subclass of UpdateError marking input-validation failures (rc=2).
8858
+
8859
+ Two cases per spec §5.1: invalid --version syntax (must match
8860
+ _SEMVER_RE), and --version with method=brew (no versioned formulae).
8861
+ Carved out of UpdateError so cmd_update's try/except can branch on
8862
+ type rather than message — the inline gates that Task 4 used both
8863
+ returned rc=2; preserving that contract is the test invariant.
8864
+ """
8865
+
8866
+
8867
+ class UpdateInProgressError(UpdateError):
8868
+ """Another update is already running. Carries the prior PID for the operator
8869
+ message ("Another update is in progress (PID 12345)."). Raised by
8870
+ _acquire_update_lock when a live PID still holds update.lock.
8871
+
8872
+ ``prior_pid`` is ``None`` when the lock body was unparseable (no
8873
+ ``PID=`` line, or non-integer value) — rendered as
8874
+ "(PID unknown)" rather than a sentinel like ``0`` (which is a real
8875
+ PID in POSIX semantics: the kernel scheduler on Linux)."""
8876
+
8877
+ def __init__(self, prior_pid: int | None):
8878
+ if prior_pid is None:
8879
+ super().__init__("Another update is in progress (PID unknown).")
8880
+ else:
8881
+ super().__init__(f"Another update is in progress (PID {prior_pid}).")
8882
+ self.prior_pid = prior_pid
8883
+
8884
+
8885
+ class UpdateCheckNetworkError(UpdateError):
8886
+ """DNS / connection / non-rate-limit HTTP failure during version check."""
8887
+
8888
+
8889
+ class UpdateCheckRateLimited(UpdateError):
8890
+ """HTTP 429 from npm registry or GitHub raw-content host. Treated as
8891
+ non-error (last-known `latest_version` preserved); banner predicate is
8892
+ still evaluated against the cached value."""
8893
+
8894
+
8895
+ class UpdateCheckHTTPError(UpdateError):
8896
+ """Non-200, non-429 HTTP status from a version-check endpoint."""
8897
+
8898
+
8899
+ class UpdateCheckParseError(UpdateError):
8900
+ """Endpoint returned a body we couldn't parse (npm JSON missing
8901
+ `version` field, formula ruby missing `version "X.Y.Z"` line)."""
8902
+
8903
+
8701
8904
  @dataclasses.dataclass
8702
8905
  class _RefreshUsageResult:
8703
8906
  """Outcome of a single _refresh_usage_inproc() invocation.
@@ -9206,6 +9409,67 @@ def _normalize_alerts_enabled_value(raw: str) -> bool:
9206
9409
  )
9207
9410
 
9208
9411
 
9412
+ # Range bound mirrors `docs/commands/update.md`: 1 hour minimum, 720 hours
9413
+ # (= 30 days) maximum. Out-of-range returns ValueError so callers in both
9414
+ # the CLI (`_cmd_config_set`) and the dashboard (`_handle_post_settings`)
9415
+ # can map to their own exit-code / HTTP-status semantics.
9416
+ _UPDATE_CHECK_TTL_HOURS_MIN = 1
9417
+ _UPDATE_CHECK_TTL_HOURS_MAX = 720
9418
+
9419
+
9420
+ def _normalize_update_check_enabled_value(raw: str) -> bool:
9421
+ """Normalize the CLI string for update.check.enabled. Reuses the
9422
+ alerts.enabled string vocabulary so users don't have to remember a
9423
+ second set of valid words.
9424
+ """
9425
+ try:
9426
+ return _normalize_alerts_enabled_value(raw)
9427
+ except ValueError:
9428
+ # Re-raise with the right key name in the message so the user
9429
+ # sees `update.check.enabled` not `alerts.enabled`.
9430
+ raise ValueError(
9431
+ f"invalid boolean value for update.check.enabled: {raw!r} "
9432
+ "(expected true|false|yes|no|1|0|on|off)"
9433
+ )
9434
+
9435
+
9436
+ def _validate_update_check_ttl_hours_value(raw) -> int:
9437
+ """Validate update.check.ttl_hours (int hours). Accepts an int or a
9438
+ string of digits; rejects bools (Python ``True`` is an int subclass
9439
+ so callers pre-validating JSON shapes must NOT pass a bool through
9440
+ here). Range bound: ``[_UPDATE_CHECK_TTL_HOURS_MIN, _MAX]``.
9441
+ """
9442
+ if isinstance(raw, bool):
9443
+ raise ValueError(
9444
+ "invalid value for update.check.ttl_hours: "
9445
+ f"{raw!r} (expected integer in "
9446
+ f"[{_UPDATE_CHECK_TTL_HOURS_MIN}, {_UPDATE_CHECK_TTL_HOURS_MAX}])"
9447
+ )
9448
+ if isinstance(raw, int):
9449
+ n = raw
9450
+ elif isinstance(raw, str):
9451
+ s = raw.strip()
9452
+ try:
9453
+ n = int(s, 10)
9454
+ except ValueError:
9455
+ raise ValueError(
9456
+ f"invalid integer for update.check.ttl_hours: {raw!r}"
9457
+ )
9458
+ else:
9459
+ raise ValueError(
9460
+ "invalid value for update.check.ttl_hours: "
9461
+ f"{raw!r} (expected integer in "
9462
+ f"[{_UPDATE_CHECK_TTL_HOURS_MIN}, {_UPDATE_CHECK_TTL_HOURS_MAX}])"
9463
+ )
9464
+ if n < _UPDATE_CHECK_TTL_HOURS_MIN or n > _UPDATE_CHECK_TTL_HOURS_MAX:
9465
+ raise ValueError(
9466
+ "update.check.ttl_hours out of range: "
9467
+ f"{n} (must be in [{_UPDATE_CHECK_TTL_HOURS_MIN}, "
9468
+ f"{_UPDATE_CHECK_TTL_HOURS_MAX}])"
9469
+ )
9470
+ return n
9471
+
9472
+
9209
9473
  _CONFIG_CORRUPT_WARNED = False # one-shot warn flag for load_config
9210
9474
  _ALERTS_BAD_CONFIG_WARNED = False # one-shot warn flag for malformed alerts block
9211
9475
 
@@ -9386,6 +9650,1484 @@ def save_config(data: dict[str, Any]) -> None:
9386
9650
  os.replace(str(tmp), str(CONFIG_PATH))
9387
9651
 
9388
9652
 
9653
+ # === update-subcommand state-file / lock / log helpers (spec §1) =========
9654
+ # These live next to load_config / save_config because they share the
9655
+ # atomic-write idiom (PID-suffixed tmp + os.replace) and the schema-
9656
+ # versioned-JSON contract. Kept stdlib-only per the project's zero-dep
9657
+ # ethos.
9658
+
9659
+ _UPDATE_STATE_SCHEMA_MAX = 1
9660
+ _UPDATE_SUPPRESS_SCHEMA_MAX = 1
9661
+
9662
+
9663
+ def _load_update_state() -> dict[str, Any] | None:
9664
+ """Read ``update-state.json``. Returns None when the file is absent
9665
+ so callers can distinguish "never checked" from "checked, no update."
9666
+
9667
+ Raises :class:`UpdateError` when ``_schema`` exceeds the highest
9668
+ version this binary knows about — forward-compat invariant from
9669
+ spec §1.7. An older cctally must NOT silently drop fields that a
9670
+ newer cctally wrote (that would invert the suppress-versions list,
9671
+ miss new check_status enum values, etc.).
9672
+
9673
+ JSON-decode errors also raise :class:`UpdateError`; the writer's
9674
+ atomic os.replace guarantees readers never see partial bytes, so a
9675
+ parse failure means the file was already corrupt before our read.
9676
+ """
9677
+ try:
9678
+ text = UPDATE_STATE_PATH.read_text(encoding="utf-8")
9679
+ except FileNotFoundError:
9680
+ return None
9681
+ try:
9682
+ data = json.loads(text)
9683
+ except json.JSONDecodeError as e:
9684
+ raise UpdateError(f"update-state.json is not valid JSON: {e}") from e
9685
+ if not isinstance(data, dict):
9686
+ raise UpdateError(
9687
+ f"update-state.json must be a JSON object, got {type(data).__name__}"
9688
+ )
9689
+ schema = data.get("_schema", 0)
9690
+ if not isinstance(schema, int) or schema > _UPDATE_STATE_SCHEMA_MAX:
9691
+ raise UpdateError(
9692
+ f"update-state.json has _schema={schema!r}; this cctally is older "
9693
+ f"than the state file. Upgrade cctally."
9694
+ )
9695
+ return data
9696
+
9697
+
9698
+ def _save_update_state(state: dict[str, Any]) -> None:
9699
+ """Persist ``update-state.json`` atomically.
9700
+
9701
+ Mirrors :func:`save_config`: PID-suffixed tmp sibling, fsync the
9702
+ bytes, then ``os.replace`` onto the final path. POSIX rename(2) is
9703
+ atomic on the same filesystem, so concurrent readers see either the
9704
+ pre-rename or post-rename contents but never partial bytes.
9705
+ Concurrent writers don't race the bytes themselves but may stomp
9706
+ each other's logical updates — the update subcommand serializes
9707
+ writers via ``UPDATE_LOCK_PATH`` (spec §5.3).
9708
+ """
9709
+ UPDATE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
9710
+ payload = (
9711
+ json.dumps(state, indent=2, sort_keys=True) + "\n"
9712
+ ).encode("utf-8")
9713
+ tmp = UPDATE_STATE_PATH.with_name(
9714
+ f"{UPDATE_STATE_PATH.name}.tmp.{os.getpid()}"
9715
+ )
9716
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
9717
+ try:
9718
+ os.write(fd, payload)
9719
+ os.fsync(fd)
9720
+ finally:
9721
+ os.close(fd)
9722
+ os.replace(str(tmp), str(UPDATE_STATE_PATH))
9723
+
9724
+
9725
+ def _load_update_suppress() -> dict[str, Any]:
9726
+ """Read ``update-suppress.json``. Returns a default empty record when
9727
+ the file is absent (spec §1.3) so the banner predicate doesn't have
9728
+ to None-guard every read. Same forward-compat schema check as
9729
+ :func:`_load_update_state`.
9730
+ """
9731
+ default = {"_schema": 1, "skipped_versions": [], "remind_after": None}
9732
+ try:
9733
+ text = UPDATE_SUPPRESS_PATH.read_text(encoding="utf-8")
9734
+ except FileNotFoundError:
9735
+ return default
9736
+ try:
9737
+ data = json.loads(text)
9738
+ except json.JSONDecodeError as e:
9739
+ raise UpdateError(
9740
+ f"update-suppress.json is not valid JSON: {e}"
9741
+ ) from e
9742
+ if not isinstance(data, dict):
9743
+ raise UpdateError(
9744
+ f"update-suppress.json must be a JSON object, got "
9745
+ f"{type(data).__name__}"
9746
+ )
9747
+ schema = data.get("_schema", 0)
9748
+ if not isinstance(schema, int) or schema > _UPDATE_SUPPRESS_SCHEMA_MAX:
9749
+ raise UpdateError(
9750
+ f"update-suppress.json has _schema={schema!r}; this cctally is "
9751
+ f"older than the suppress file. Upgrade cctally."
9752
+ )
9753
+ return data
9754
+
9755
+
9756
+ def _save_update_suppress(suppress: dict[str, Any]) -> None:
9757
+ """Persist ``update-suppress.json`` atomically. Same idiom as
9758
+ :func:`_save_update_state`."""
9759
+ UPDATE_SUPPRESS_PATH.parent.mkdir(parents=True, exist_ok=True)
9760
+ payload = (
9761
+ json.dumps(suppress, indent=2, sort_keys=True) + "\n"
9762
+ ).encode("utf-8")
9763
+ tmp = UPDATE_SUPPRESS_PATH.with_name(
9764
+ f"{UPDATE_SUPPRESS_PATH.name}.tmp.{os.getpid()}"
9765
+ )
9766
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
9767
+ try:
9768
+ os.write(fd, payload)
9769
+ os.fsync(fd)
9770
+ finally:
9771
+ os.close(fd)
9772
+ os.replace(str(tmp), str(UPDATE_SUPPRESS_PATH))
9773
+
9774
+
9775
+ def _read_lock_pid(fd: int) -> int | None:
9776
+ """Parse ``PID=<n>`` out of an open update.lock fd. Returns None on
9777
+ any failure (file empty, missing PID line, non-integer value) — the
9778
+ caller treats "unknown holder" the same as a stale lock and
9779
+ attempts a second LOCK_NB acquire."""
9780
+ try:
9781
+ os.lseek(fd, 0, 0)
9782
+ body = os.read(fd, 1024).decode("utf-8")
9783
+ except OSError:
9784
+ return None
9785
+ for line in body.splitlines():
9786
+ if line.startswith("PID="):
9787
+ try:
9788
+ return int(line[4:])
9789
+ except ValueError:
9790
+ return None
9791
+ return None
9792
+
9793
+
9794
+ def _acquire_update_lock() -> int:
9795
+ """Acquire the singleton update.lock under spec §5.3 contract.
9796
+
9797
+ Returns the open fd on success. Caller MUST pass the fd to
9798
+ :func:`_release_update_lock` to drop the flock + unlink the file.
9799
+
9800
+ Raises :class:`UpdateInProgressError` when a *live* PID still holds
9801
+ the lock. Stale locks (writer crashed without releasing) are
9802
+ silently reclaimed: ``kill(pid, 0)`` raising ``ProcessLookupError``
9803
+ is the only signal we trust for reclaim — kernel-authoritative,
9804
+ free of read-the-file-then-stat races.
9805
+
9806
+ Body format (text, line-oriented for ``cat update.lock``)::
9807
+
9808
+ PID=12345
9809
+ STARTED_AT_UTC=2026-05-10T13:05:23+00:00
9810
+ COMMAND=cctally update
9811
+ """
9812
+ UPDATE_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
9813
+ fd = os.open(
9814
+ str(UPDATE_LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o644
9815
+ )
9816
+ try:
9817
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
9818
+ except BlockingIOError:
9819
+ prior = _read_lock_pid(fd)
9820
+ if prior is not None:
9821
+ try:
9822
+ os.kill(prior, 0)
9823
+ except ProcessLookupError:
9824
+ pass # stale → fall through to reclaim attempt
9825
+ else:
9826
+ # Live PID still holds the lock — refuse.
9827
+ os.close(fd)
9828
+ raise UpdateInProgressError(prior)
9829
+ # Stale (or unparseable PID): retry the non-blocking acquire.
9830
+ # If it still fails, another process raced us into the same
9831
+ # reclaim path; surface it as in-progress with the best PID
9832
+ # we observed (or 0 if we couldn't read one).
9833
+ try:
9834
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
9835
+ except BlockingIOError:
9836
+ os.close(fd)
9837
+ raise UpdateInProgressError(prior)
9838
+ os.ftruncate(fd, 0)
9839
+ body = (
9840
+ f"PID={os.getpid()}\n"
9841
+ f"STARTED_AT_UTC={_now_utc().isoformat()}\n"
9842
+ f"COMMAND=cctally update\n"
9843
+ ).encode("utf-8")
9844
+ os.write(fd, body)
9845
+ return fd
9846
+
9847
+
9848
+ def _release_update_lock(fd: int) -> None:
9849
+ """Drop the flock and close the fd. The lock file persists.
9850
+
9851
+ Defensive on every step: a double-release (or a release after the
9852
+ fd has been closed by an earlier error path) must not raise.
9853
+
9854
+ The file at ``UPDATE_LOCK_PATH`` is deliberately NOT unlinked.
9855
+ ``flock`` locks the inode behind the fd, not the path: unlinking
9856
+ after release lets a peer that ``O_CREAT``ed a new inode at the
9857
+ same path hold a "lock" on a different inode from a peer that
9858
+ still references the old one — concurrent updates. Leaving the
9859
+ file in place pins all acquires to a single inode; the kernel's
9860
+ flock state is the sole synchronization primitive. ``_acquire_..``
9861
+ handles the persistent-file case (O_CREAT + ftruncate + rewrite
9862
+ on every acquire).
9863
+ """
9864
+ try:
9865
+ fcntl.flock(fd, fcntl.LOCK_UN)
9866
+ except OSError:
9867
+ pass
9868
+ try:
9869
+ os.close(fd)
9870
+ except OSError:
9871
+ pass
9872
+
9873
+
9874
+ def _rotate_update_log_if_needed() -> None:
9875
+ """Rotate ``update.log`` → ``update.log.1`` when the live log
9876
+ crosses :data:`UPDATE_LOG_ROTATE_BYTES` (1 MB, spec §1.5).
9877
+
9878
+ Single rotation slot: a second rotation overwrites the first.
9879
+ Failed-install logs are preserved on disk only until the next
9880
+ successful run grows the live log past 1 MB — operators chasing a
9881
+ historical failure should grab ``update.log.1`` while it's still
9882
+ around.
9883
+
9884
+ No-op when the file is absent or below threshold.
9885
+ """
9886
+ try:
9887
+ size = UPDATE_LOG_PATH.stat().st_size
9888
+ except FileNotFoundError:
9889
+ return
9890
+ if size < UPDATE_LOG_ROTATE_BYTES:
9891
+ return
9892
+ try:
9893
+ UPDATE_LOG_ROTATED_PATH.unlink()
9894
+ except FileNotFoundError:
9895
+ pass
9896
+ UPDATE_LOG_PATH.rename(UPDATE_LOG_ROTATED_PATH)
9897
+
9898
+
9899
+ def _log_update_event(log_fd, event: str, **kv: Any) -> None:
9900
+ """Append one event line to ``update.log``.
9901
+
9902
+ Format: ``<iso-utc> <EVENT> k=v k=v ...``. Strings containing
9903
+ spaces are wrapped with ``repr`` so the log stays grep-friendly;
9904
+ integers are emitted bare so size/elapsed columns can be
9905
+ arithmetic-parsed. ``log_fd`` is any text-mode writable file-like
9906
+ (``open(UPDATE_LOG_PATH, "a", encoding="utf-8")`` is the production
9907
+ caller from Task 5).
9908
+ """
9909
+ parts = [_now_utc().isoformat(), event]
9910
+ for k, v in kv.items():
9911
+ if isinstance(v, str) and " " in v:
9912
+ parts.append(f"{k}={v!r}")
9913
+ else:
9914
+ parts.append(f"{k}={v}")
9915
+ log_fd.write(" ".join(parts) + "\n")
9916
+ log_fd.flush()
9917
+
9918
+
9919
+ # === Update subcommand: install-method detection (spec §2) =================
9920
+ # Path-based heuristic over `realpath(sys.argv[0])`:
9921
+ # - "/Cellar/cctally/" substring → method="brew" (Apple Silicon, Intel,
9922
+ # and Linuxbrew all funnel through `<root>/Cellar/cctally/`).
9923
+ # - "<npm-prefix>/lib/node_modules/cctally/" prefix → method="npm".
9924
+ # - Anything else (source install, pnpm/yarn-global/volta, dev symlink)
9925
+ # → method="unknown" → manual-fallback bucket per spec §2.4.
9926
+ # `mutate=False` is the dry-run contract (§5.5): every tier still
9927
+ # computes, but tier-C cache writes to update-state.json are skipped.
9928
+
9929
+
9930
+ @dataclass(frozen=True)
9931
+ class InstallMethod:
9932
+ """Resolved install method for the running cctally binary (spec §2.1).
9933
+
9934
+ ``method`` is one of ``"brew"``, ``"npm"``, ``"unknown"``;
9935
+ ``realpath`` is ``os.path.realpath(sys.argv[0])`` (the resolved
9936
+ target of any symlinks on $PATH); ``npm_prefix`` is populated only
9937
+ when ``method == "npm"`` so callers don't have to special-case it.
9938
+ """
9939
+
9940
+ method: str
9941
+ realpath: str
9942
+ npm_prefix: str | None
9943
+
9944
+
9945
+ def _resolve_npm_prefix(*, mutate: bool = True) -> str | None:
9946
+ """Three-tier ``npm prefix -g`` resolution (spec §2.2).
9947
+
9948
+ Tier A: ``$npm_config_prefix`` env var (rarely set; free).
9949
+ Tier B: cached ``install.npm_prefix`` from update-state.json,
9950
+ 7-day TTL (one ``os.stat`` via ``_load_update_state``).
9951
+ Tier C: ``subprocess.run(["npm", "prefix", "-g"], timeout=2.0)``
9952
+ (200–300 ms cold). Tier-C success populates tier-B only when
9953
+ ``mutate=True``; failure (npm not on PATH, timeout, non-zero
9954
+ exit) returns ``None`` regardless of ``mutate``.
9955
+ """
9956
+ # Tier A — env var short-circuit.
9957
+ env_pref = os.environ.get("npm_config_prefix")
9958
+ if env_pref and pathlib.Path(env_pref).is_dir():
9959
+ return env_pref
9960
+ # Tier B — cached state-file value within 7-day TTL.
9961
+ state = _load_update_state()
9962
+ if state and isinstance(state.get("install"), dict):
9963
+ cached = state["install"].get("npm_prefix")
9964
+ detected_iso = state["install"].get("detected_at_utc")
9965
+ if cached and detected_iso:
9966
+ try:
9967
+ detected = dt.datetime.fromisoformat(detected_iso)
9968
+ age = (_now_utc() - detected).total_seconds()
9969
+ if age < UPDATE_NPM_PREFIX_TTL_DAYS * 86400:
9970
+ return cached
9971
+ except (ValueError, TypeError):
9972
+ # Malformed timestamp → fall through to tier C.
9973
+ pass
9974
+ # Tier C — subprocess. Treat any failure as "unknown npm prefix"
9975
+ # rather than raising; the caller maps None → method="unknown".
9976
+ try:
9977
+ result = subprocess.run(
9978
+ ["npm", "prefix", "-g"],
9979
+ timeout=UPDATE_NPM_PREFIX_TIMEOUT_S,
9980
+ capture_output=True,
9981
+ text=True,
9982
+ )
9983
+ except (FileNotFoundError, subprocess.TimeoutExpired):
9984
+ return None
9985
+ if result.returncode != 0:
9986
+ return None
9987
+ prefix = result.stdout.strip()
9988
+ if not prefix:
9989
+ return None
9990
+ if mutate:
9991
+ _persist_npm_prefix_to_state(prefix)
9992
+ return prefix
9993
+
9994
+
9995
+ def _persist_npm_prefix_to_state(prefix: str) -> None:
9996
+ """Write ``install.npm_prefix`` + ``install.detected_at_utc`` to
9997
+ update-state.json, preserving every other field. Used only by
9998
+ tier-C of :func:`_resolve_npm_prefix` when ``mutate=True``.
9999
+ """
10000
+ state = _load_update_state() or {"_schema": 1}
10001
+ state.setdefault("install", {})
10002
+ state["install"]["npm_prefix"] = prefix
10003
+ state["install"]["detected_at_utc"] = _now_utc().isoformat()
10004
+ _save_update_state(state)
10005
+
10006
+
10007
+ def _detect_install_method(*, mutate: bool = True) -> InstallMethod:
10008
+ """Detect how the running cctally was installed (spec §2.1).
10009
+
10010
+ Path-based heuristic — see module-level comment above
10011
+ :class:`InstallMethod` for the algorithm. ``mutate=False`` honours
10012
+ the ``--dry-run`` "touch nothing" contract: detection still runs,
10013
+ but neither the npm-prefix tier-B cache nor the install block is
10014
+ persisted to update-state.json.
10015
+ """
10016
+ real = os.path.realpath(sys.argv[0])
10017
+ if "/Cellar/cctally/" in real:
10018
+ method = InstallMethod(method="brew", realpath=real, npm_prefix=None)
10019
+ else:
10020
+ prefix = _resolve_npm_prefix(mutate=mutate)
10021
+ if prefix:
10022
+ nm_root = os.path.join(prefix, "lib", "node_modules", "cctally")
10023
+ if real == nm_root or real.startswith(nm_root + os.sep):
10024
+ method = InstallMethod(
10025
+ method="npm", realpath=real, npm_prefix=prefix
10026
+ )
10027
+ else:
10028
+ method = InstallMethod(
10029
+ method="unknown", realpath=real, npm_prefix=None
10030
+ )
10031
+ else:
10032
+ method = InstallMethod(
10033
+ method="unknown", realpath=real, npm_prefix=None
10034
+ )
10035
+ if mutate:
10036
+ _persist_install_method_to_state(method)
10037
+ return method
10038
+
10039
+
10040
+ def _persist_install_method_to_state(method: InstallMethod) -> None:
10041
+ """Replace the ``install`` block in update-state.json with a fresh
10042
+ detection result, preserving every other field (e.g. ``latest_version``
10043
+ written by the version-check pipeline in Task 3). ``current_version``
10044
+ is also re-stamped from the CHANGELOG so the running binary's
10045
+ self-version stays in sync with the install block.
10046
+ """
10047
+ state = _load_update_state() or {"_schema": 1}
10048
+ state["install"] = {
10049
+ "method": method.method,
10050
+ "realpath": method.realpath,
10051
+ "npm_prefix": method.npm_prefix,
10052
+ "detected_at_utc": _now_utc().isoformat(),
10053
+ }
10054
+ cur = _release_read_latest_release_version()
10055
+ if cur:
10056
+ state["current_version"] = cur[0]
10057
+ _save_update_state(state)
10058
+
10059
+
10060
+ def _stamp_install_success_to_state(installed_version: str | None) -> None:
10061
+ """Stamp ``update-state.json`` with the just-installed version so the
10062
+ post-install banner predicate + dashboard auto-close fire immediately.
10063
+
10064
+ Without this, both surfaces are stuck for up to ``ttl_hours`` (24h
10065
+ default): ``_do_update_check`` touched the throttle marker before
10066
+ install began, so ``_is_update_check_due`` returns False on every
10067
+ subsequent boot until the TTL expires; ``current_version`` would
10068
+ keep its pre-install value, and ``_semver_gt(latest, current)``
10069
+ stays True. Banner re-fires on every CLI command; dashboard's
10070
+ ``refreshUpdateState`` auto-close (``current === latest``) never
10071
+ matches.
10072
+
10073
+ Falls back to ``state.latest_version`` when the caller didn't pass
10074
+ an explicit ``--version`` — brew is always unpinned, and an
10075
+ unpinned npm install resolves to whatever ``@latest`` advertised,
10076
+ which is the value we just told the user about.
10077
+ """
10078
+ state = _load_update_state() or {"_schema": 1}
10079
+ cur = installed_version or state.get("latest_version")
10080
+ if cur:
10081
+ state["current_version"] = cur
10082
+ state["last_install_success_at_utc"] = _now_utc().isoformat()
10083
+ _save_update_state(state)
10084
+
10085
+
10086
+ # === Update subcommand: version-check pipeline (spec §3) ====================
10087
+ # Per-vector parsers, TTL gate, and the chokepoint `_do_update_check` that
10088
+ # touches the throttle marker FIRST (crash safety) before attempting any
10089
+ # remote fetch. Failures preserve the prior state's `latest_version` so the
10090
+ # banner predicate can still fire on the last-known-good value.
10091
+
10092
+ # Priority regex chain for `_check_brew_latest_version`. First match wins:
10093
+ # 1. Explicit `version "X.Y.Z"` line (homebrew's preferred form).
10094
+ # 2. Archive URL `/vX.Y.Z[-prerelease.N].tar` (auto-archive form).
10095
+ # 3. Tag form `tag: "[v]X.Y.Z"` (occasionally seen in head/url blocks).
10096
+ _BREW_VERSION_RE_LIST = (
10097
+ re.compile(r'^\s*version\s+"([^"]+)"\s*$', re.MULTILINE),
10098
+ re.compile(
10099
+ r'url\s+"[^"]*/v(\d+\.\d+\.\d+(?:-[a-zA-Z][a-zA-Z0-9-]*\.\d+)?)\.tar',
10100
+ re.MULTILINE,
10101
+ ),
10102
+ re.compile(
10103
+ r'tag:\s*"v?(\d+\.\d+\.\d+(?:-[a-zA-Z][a-zA-Z0-9-]*\.\d+)?)"',
10104
+ re.MULTILINE,
10105
+ ),
10106
+ )
10107
+
10108
+
10109
+ def _update_user_agent() -> str:
10110
+ """User-Agent for `_fetch_url` HTTP requests.
10111
+
10112
+ Format: ``cctally-update-check/<version>``. Sources the version from
10113
+ the CHANGELOG (same chokepoint as every other "what version am I"
10114
+ callsite); falls back to ``"dev"`` for pre-release / unstamped trees.
10115
+ """
10116
+ cur = _release_read_latest_release_version()
10117
+ ver = cur[0] if cur else "dev"
10118
+ return f"cctally-update-check/{ver}"
10119
+
10120
+
10121
+ def _fetch_url(url: str, *, timeout: float = UPDATE_NETWORK_TIMEOUT_S) -> tuple[int, bytes]:
10122
+ """Stdlib urllib HTTP GET. Raises typed exceptions on failure.
10123
+
10124
+ Returns ``(status_code, body_bytes)`` on success. Maps urllib failures
10125
+ to the four `UpdateCheck*` exception types so callers can distinguish
10126
+ "rate-limited (try again later)" from "HTTP fetch failed (treat as
10127
+ last-known-good)" from "DNS / network down".
10128
+ """
10129
+ req = urllib.request.Request(url, headers={
10130
+ "User-Agent": _update_user_agent(),
10131
+ "Accept": "*/*",
10132
+ })
10133
+ try:
10134
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
10135
+ return (resp.status, resp.read())
10136
+ except urllib.error.HTTPError as e:
10137
+ if e.code == 429:
10138
+ raise UpdateCheckRateLimited(str(e))
10139
+ raise UpdateCheckHTTPError(f"HTTP {e.code}: {e}")
10140
+ except (urllib.error.URLError, TimeoutError) as e:
10141
+ # URLError covers connection-setup failures; TimeoutError
10142
+ # (socket.timeout's alias since 3.10) covers stalls during
10143
+ # resp.read() — that path raises directly through http.client
10144
+ # without urllib wrapping. Both must funnel to
10145
+ # UpdateCheckNetworkError so _do_update_check translates them
10146
+ # into check_status="fetch_failed" instead of letting a slow
10147
+ # registry crash --check with an uncaught traceback.
10148
+ raise UpdateCheckNetworkError(str(e))
10149
+
10150
+
10151
+ def _check_npm_latest_version() -> str:
10152
+ """Fetch the npm-registry `latest` JSON and return its `version` field.
10153
+
10154
+ Endpoint: :data:`UPDATE_NPM_REGISTRY_URL` (env-overridable via
10155
+ ``CCTALLY_TEST_UPDATE_NPM_URL`` for fixture testing). JSON decode
10156
+ errors and missing-key errors raise :class:`UpdateCheckParseError`.
10157
+ """
10158
+ status, body = _fetch_url(UPDATE_NPM_REGISTRY_URL)
10159
+ try:
10160
+ data = json.loads(body.decode("utf-8"))
10161
+ return data["version"]
10162
+ except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
10163
+ raise UpdateCheckParseError(f"npm registry parse failed: {e}")
10164
+
10165
+
10166
+ def _check_brew_latest_version() -> str:
10167
+ """Fetch the brew formula raw blob and extract the version.
10168
+
10169
+ Endpoint: :data:`UPDATE_BREW_FORMULA_URL`. Applies
10170
+ :data:`_BREW_VERSION_RE_LIST` in priority order; first match wins.
10171
+ No regex matches → :class:`UpdateCheckParseError`.
10172
+ """
10173
+ status, body = _fetch_url(UPDATE_BREW_FORMULA_URL)
10174
+ try:
10175
+ text = body.decode("utf-8")
10176
+ except UnicodeDecodeError as e:
10177
+ raise UpdateCheckParseError(f"brew formula decode failed: {e}")
10178
+ for pattern in _BREW_VERSION_RE_LIST:
10179
+ m = pattern.search(text)
10180
+ if m:
10181
+ return m.group(1)
10182
+ raise UpdateCheckParseError("brew formula version not found")
10183
+
10184
+
10185
+ def _is_update_check_due(config: dict) -> bool:
10186
+ """TTL gate (spec §3.4).
10187
+
10188
+ Reads ``update.check.enabled`` (default True) and
10189
+ ``update.check.ttl_hours`` (default :data:`UPDATE_DEFAULT_TTL_HOURS`)
10190
+ from the config. Returns False if disabled. Returns True if the
10191
+ throttle marker (:data:`UPDATE_CHECK_LAST_FETCH_PATH`) is missing.
10192
+ Otherwise: ``(now - mtime) >= ttl * 3600``.
10193
+ """
10194
+ check_cfg = (config.get("update", {}) or {}).get("check", {}) or {}
10195
+ enabled = check_cfg.get("enabled", True)
10196
+ if not enabled:
10197
+ return False
10198
+ ttl_hours = check_cfg.get("ttl_hours", UPDATE_DEFAULT_TTL_HOURS)
10199
+ try:
10200
+ mtime = UPDATE_CHECK_LAST_FETCH_PATH.stat().st_mtime
10201
+ except FileNotFoundError:
10202
+ return True
10203
+ return (time.time() - mtime) >= ttl_hours * 3600
10204
+
10205
+
10206
+ def _do_update_check() -> None:
10207
+ """Single chokepoint for a version-check fetch (spec §3.5).
10208
+
10209
+ Touches the throttle marker FIRST (crash-safety: if the process
10210
+ dies mid-fetch, we still won't refetch for the full TTL window —
10211
+ avoids hammering the registry on a flapping host). Then resolves
10212
+ install method, ensures `current_version` is stamped from CHANGELOG,
10213
+ preserves prior `latest_version` if any, and dispatches to the
10214
+ per-vector check by `method.method`. On success: write
10215
+ `check_status="ok"` + `latest_version_url`. On failure: map the
10216
+ typed exception to a `check_status` enum (`rate_limited` /
10217
+ `fetch_failed` / `parse_failed`); never lose the prior
10218
+ `latest_version`. State is saved unconditionally on the way out.
10219
+ """
10220
+ # Touch marker FIRST — crash safety: a dead process mid-fetch must
10221
+ # not trigger another fetch within the TTL window.
10222
+ UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
10223
+ UPDATE_CHECK_LAST_FETCH_PATH.touch()
10224
+
10225
+ method = _detect_install_method(mutate=True)
10226
+
10227
+ state = _load_update_state() or {"_schema": 1}
10228
+ cur = _release_read_latest_release_version()
10229
+ if cur:
10230
+ state["current_version"] = cur[0]
10231
+ # Preserve prior `latest_version`; default to current_version if
10232
+ # nothing was ever recorded (so banner predicate has a comparable).
10233
+ state.setdefault("latest_version", state.get("current_version"))
10234
+ state["checked_at_utc"] = _now_utc().isoformat()
10235
+ state["check_error"] = None
10236
+
10237
+ try:
10238
+ if method.method == "npm":
10239
+ latest = _check_npm_latest_version()
10240
+ state["latest_version"] = latest
10241
+ state["source"] = "npm-registry"
10242
+ elif method.method == "brew":
10243
+ latest = _check_brew_latest_version()
10244
+ state["latest_version"] = latest
10245
+ state["source"] = "github-formula"
10246
+ else:
10247
+ # Unknown install method — no remote check possible
10248
+ # (manual-fallback bucket per §2.4). Reset `latest_version`
10249
+ # to `current_version` so the banner predicate's
10250
+ # `_semver_gt(lat, cur)` returns False; preserving a prior
10251
+ # npm/brew latest here would advertise an update that
10252
+ # `cctally update` cannot apply (install method is now
10253
+ # unknown). The setdefault above is insufficient because
10254
+ # state may already carry a `latest_version` from an
10255
+ # earlier npm/brew install before the user switched to a
10256
+ # source checkout. Same suppression flows to the dashboard
10257
+ # amber badge, which reads `latest_version` directly.
10258
+ state["latest_version"] = state.get("current_version")
10259
+ state["check_status"] = "unavailable"
10260
+ _save_update_state(state)
10261
+ return
10262
+ # Success: build the public-mirror release tag URL.
10263
+ state["latest_version_url"] = (
10264
+ f"https://github.com/{PUBLIC_REPO}/releases/tag/v{state['latest_version']}"
10265
+ )
10266
+ state["check_status"] = "ok"
10267
+ except UpdateCheckRateLimited as e:
10268
+ state["check_status"] = "rate_limited"
10269
+ state["check_error"] = str(e)[:200]
10270
+ except (UpdateCheckNetworkError, UpdateCheckHTTPError) as e:
10271
+ state["check_status"] = "fetch_failed"
10272
+ state["check_error"] = str(e)[:200]
10273
+ except UpdateCheckParseError as e:
10274
+ state["check_status"] = "parse_failed"
10275
+ state["check_error"] = str(e)[:200]
10276
+ finally:
10277
+ _save_update_state(state)
10278
+
10279
+
10280
+ def _spawn_background_update_check() -> None:
10281
+ """Fire-and-forget the hidden `_update-check` worker.
10282
+
10283
+ Detached `subprocess.Popen` with `start_new_session=True` so a
10284
+ parent exit (the user closes the shell) doesn't propagate SIGHUP
10285
+ to the child. stdin/stdout/stderr are all `/dev/null` so the child
10286
+ can't accidentally pollute the parent's terminal. Exceptions are
10287
+ swallowed: a failed spawn must not break the parent command.
10288
+ """
10289
+ try:
10290
+ subprocess.Popen(
10291
+ [sys.executable, os.path.realpath(sys.argv[0]), "_update-check"],
10292
+ stdin=subprocess.DEVNULL,
10293
+ stdout=subprocess.DEVNULL,
10294
+ stderr=subprocess.DEVNULL,
10295
+ start_new_session=True,
10296
+ close_fds=True,
10297
+ )
10298
+ except Exception:
10299
+ # Fire-and-forget: never let a spawn failure propagate.
10300
+ pass
10301
+
10302
+
10303
+ def cmd_update_check_internal(args) -> int:
10304
+ """Hidden ``_update-check`` subcommand handler (spec §3.6).
10305
+
10306
+ The detached-refresh worker — not user-facing. Logs lifecycle
10307
+ events to ``update.log`` and rotates if needed. Always returns 0
10308
+ (any error is logged but the process exits cleanly so the parent
10309
+ spawn-and-forget contract holds).
10310
+ """
10311
+ # Ensure APP_DIR exists so log + state writes succeed on first run.
10312
+ UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
10313
+ try:
10314
+ with open(UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
10315
+ _log_update_event(log_fd, "CHECK_START")
10316
+ _do_update_check()
10317
+ _log_update_event(log_fd, "CHECK_EXIT", rc=0)
10318
+ except Exception as e:
10319
+ try:
10320
+ with open(UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
10321
+ _log_update_event(log_fd, "CHECK_EXIT", rc=1, error=str(e)[:200])
10322
+ except Exception:
10323
+ pass
10324
+ _rotate_update_log_if_needed()
10325
+ return 0
10326
+
10327
+
10328
+ # === User-facing `cctally update` (spec §4) ===
10329
+ # `cmd_update` routes by mode flag. Mode flags are mutually exclusive
10330
+ # (argparse enforces it; the dispatcher's redundant check is defense in
10331
+ # depth for programmatic callers and a clearer error message). The
10332
+ # install path is staged across two tasks: Task 4 lands the validation
10333
+ # gates and the user-mode `--check` rendering, then raises
10334
+ # NotImplementedError for actual execution. Task 5 fills in execvp +
10335
+ # streaming.
10336
+
10337
+ # Sentinel for `--skip` with no positional argument — argparse `const`
10338
+ # doesn't accept `None` (collides with the absent-flag default). At
10339
+ # dispatch time the sentinel is replaced with `state.latest_version`.
10340
+ SKIP_USE_STATE_LATEST = "_USE_STATE_LATEST"
10341
+
10342
+
10343
+ def _format_update_command(method: str, version: str | None) -> str:
10344
+ """One-line shell recipe used by both --check renderers and the
10345
+ install-path manual fallback. Brew has no versioned formulae, so
10346
+ the version arg is ignored there (callers gate it earlier)."""
10347
+ if method == "brew":
10348
+ return "brew update --quiet && brew upgrade cctally"
10349
+ if method == "npm":
10350
+ v = version if version else "latest"
10351
+ return f"npm install -g cctally@{v}"
10352
+ return ""
10353
+
10354
+
10355
+ def _prerelease_note(current: str) -> str | None:
10356
+ """Spec §1.8 — prerelease users get a one-shot informational note in
10357
+ `--check` output. Returns the canned two-line message verbatim per
10358
+ spec when `current` looks like a prerelease (`X.Y.Z-id.N` form), else
10359
+ None. Wording is exact-string contract — tests pin it."""
10360
+ if "-" not in current:
10361
+ return None
10362
+ return (
10363
+ f"You're on prerelease {current}; this banner suggests stable.\n"
10364
+ "To track prereleases, manage manually: npm install -g cctally@next"
10365
+ )
10366
+
10367
+
10368
+ def _format_update_check_json(
10369
+ state: dict[str, Any], suppress: dict[str, Any]
10370
+ ) -> dict[str, Any]:
10371
+ """JSON shape for `cctally update --check --json` (spec §4.4)."""
10372
+ cur = state.get("current_version")
10373
+ lat = state.get("latest_version")
10374
+ method = (state.get("install") or {}).get("method", "unknown")
10375
+ skipped = lat in suppress.get("skipped_versions", []) if lat else False
10376
+ in_remind_window = False
10377
+ remind = suppress.get("remind_after")
10378
+ if remind is not None and lat is not None:
10379
+ try:
10380
+ if not _semver_gt(lat, remind["version"]):
10381
+ until = dt.datetime.fromisoformat(remind["until_utc"])
10382
+ if _now_utc() < until:
10383
+ in_remind_window = True
10384
+ except (KeyError, ValueError):
10385
+ pass
10386
+ available = False
10387
+ if cur and lat:
10388
+ try:
10389
+ available = (
10390
+ _semver_gt(lat, cur)
10391
+ and not skipped
10392
+ and not in_remind_window
10393
+ )
10394
+ except ValueError:
10395
+ available = False
10396
+ return {
10397
+ "_schema": 1,
10398
+ "current_version": cur,
10399
+ "latest_version": lat,
10400
+ "available": available,
10401
+ "method": method,
10402
+ "update_command": _format_update_command(method, None),
10403
+ "release_notes_url": state.get("latest_version_url"),
10404
+ "check_status": state.get("check_status"),
10405
+ "check_error": state.get("check_error"),
10406
+ "checked_at_utc": state.get("checked_at_utc"),
10407
+ "suppress": {
10408
+ "skipped": skipped,
10409
+ "remind_after_utc": (
10410
+ remind.get("until_utc") if isinstance(remind, dict) else None
10411
+ ),
10412
+ },
10413
+ "prerelease_note": _prerelease_note(cur) if cur else None,
10414
+ }
10415
+
10416
+
10417
+ _UPDATE_METHOD_HUMAN_LABEL = {
10418
+ "brew": "Homebrew",
10419
+ "npm": "npm",
10420
+ "unknown": "unknown",
10421
+ }
10422
+
10423
+
10424
+ def _format_update_check_human(
10425
+ state: dict[str, Any], suppress: dict[str, Any]
10426
+ ) -> str:
10427
+ """Multi-line plaintext block for `cctally update --check` (spec §4.4).
10428
+
10429
+ Two-space-column table layout: every label left-padded to width 10
10430
+ (`Will run` is the longest at 8 chars + 2-space gutter). Method row
10431
+ appends ` (auto-detected)` per spec example. Up-to-date / unknown
10432
+ variants append a fallback line below the table.
10433
+ """
10434
+ cur = state.get("current_version") or "unknown"
10435
+ lat = state.get("latest_version") or "unknown"
10436
+ method = (state.get("install") or {}).get("method", "unknown")
10437
+ url = state.get("latest_version_url")
10438
+ status = state.get("check_status")
10439
+ err = state.get("check_error")
10440
+ cooked_available = False
10441
+ if state.get("current_version") and state.get("latest_version"):
10442
+ try:
10443
+ cooked_available = _semver_gt(lat, cur) and \
10444
+ lat not in suppress.get("skipped_versions", [])
10445
+ except ValueError:
10446
+ cooked_available = False
10447
+
10448
+ method_label = _UPDATE_METHOD_HUMAN_LABEL.get(method, method)
10449
+ lines = [
10450
+ f"{'Current':<10}{cur}",
10451
+ f"{'Latest':<10}{lat}",
10452
+ f"{'Method':<10}{method_label} (auto-detected)",
10453
+ ]
10454
+ will_run = _format_update_command(method, None)
10455
+ if will_run:
10456
+ lines.append(f"{'Will run':<10}{will_run}")
10457
+ if url:
10458
+ lines.append(f"{'Notes':<10}{url}")
10459
+ if status and status != "ok":
10460
+ status_value = status + (f" ({err})" if err else "")
10461
+ lines.append(f"{'Status':<10}{status_value}")
10462
+ lines.append("")
10463
+ if method == "unknown":
10464
+ # No remote check is possible for source / dev installs; render
10465
+ # the manual fallback rather than the "you're up to date" lie.
10466
+ lines.append(
10467
+ "Automatic update unavailable for this install. Visit "
10468
+ f"{url or 'https://github.com/' + PUBLIC_REPO + '/releases'} "
10469
+ "to install manually."
10470
+ )
10471
+ elif cooked_available:
10472
+ lines.append("Run `cctally update` to install.")
10473
+ else:
10474
+ lines.append("You're up to date.")
10475
+ note = _prerelease_note(cur)
10476
+ if note:
10477
+ lines.append("")
10478
+ lines.append(note)
10479
+ return "\n".join(lines)
10480
+
10481
+
10482
+ def _do_update_skip(version_arg: str) -> int:
10483
+ """`cctally update --skip [VERSION]` — record a skipped version."""
10484
+ if version_arg == SKIP_USE_STATE_LATEST:
10485
+ state = _load_update_state()
10486
+ if state is None or not state.get("latest_version"):
10487
+ print(
10488
+ "cctally update: no version in cache to skip; run "
10489
+ "`cctally update --check` first",
10490
+ file=sys.stderr,
10491
+ )
10492
+ return 1
10493
+ version = state["latest_version"]
10494
+ else:
10495
+ if not _SEMVER_RE.match(version_arg):
10496
+ print(
10497
+ f"cctally update: invalid version {version_arg!r} "
10498
+ "(expected X.Y.Z[-id.N])",
10499
+ file=sys.stderr,
10500
+ )
10501
+ return 2
10502
+ version = version_arg
10503
+ suppress = _load_update_suppress()
10504
+ skipped = list(suppress.get("skipped_versions", []))
10505
+ if version not in skipped:
10506
+ skipped.append(version)
10507
+ suppress["skipped_versions"] = skipped
10508
+ suppress.setdefault("_schema", 1)
10509
+ _save_update_suppress(suppress)
10510
+ print(
10511
+ f"Skipped cctally {version}. You won't be reminded about this version."
10512
+ )
10513
+ return 0
10514
+
10515
+
10516
+ def _do_update_remind_later(days: int) -> int:
10517
+ """`cctally update --remind-later [DAYS]` — defer banner for N days."""
10518
+ if not (1 <= days <= 365):
10519
+ print(
10520
+ f"cctally update: --remind-later must be 1..365 (got {days})",
10521
+ file=sys.stderr,
10522
+ )
10523
+ return 2
10524
+ state = _load_update_state()
10525
+ if state is None or not state.get("latest_version"):
10526
+ print(
10527
+ "cctally update: no version in cache to defer; run "
10528
+ "`cctally update --check` first",
10529
+ file=sys.stderr,
10530
+ )
10531
+ return 1
10532
+ until = (_now_utc() + dt.timedelta(days=days)).isoformat()
10533
+ suppress = _load_update_suppress()
10534
+ suppress["remind_after"] = {
10535
+ "version": state["latest_version"],
10536
+ "until_utc": until,
10537
+ }
10538
+ suppress.setdefault("_schema", 1)
10539
+ _save_update_suppress(suppress)
10540
+ print(
10541
+ f"Will remind in {days} day{'s' if days != 1 else ''} "
10542
+ "(or sooner if a newer version drops)."
10543
+ )
10544
+ return 0
10545
+
10546
+
10547
+ _UPDATE_CHECK_JSON_UNAVAILABLE_ENVELOPE = {
10548
+ "_schema": 1,
10549
+ "current_version": None,
10550
+ "latest_version": None,
10551
+ "available": False,
10552
+ "method": "unknown",
10553
+ "update_command": None,
10554
+ "release_notes_url": None,
10555
+ "check_status": "unavailable",
10556
+ "check_error": "state unavailable",
10557
+ "checked_at_utc": None,
10558
+ "suppress": {"skipped": False, "remind_after_utc": None},
10559
+ "prerelease_note": None,
10560
+ }
10561
+
10562
+
10563
+ def _do_update_check_user(*, force: bool, output_json: bool) -> int:
10564
+ """`cctally update --check` — user-facing version-check render.
10565
+
10566
+ `_do_update_check()` translates known failure modes (network,
10567
+ parse, rate-limit) into `check_status` fields on the state file
10568
+ via its own internal try/except (Task 3, spec §3.5); any unexpected
10569
+ exception that escapes is a real bug and is left to surface in the
10570
+ outer error log. The post-command banner hook in `main()` already
10571
+ isolates banner failures.
10572
+
10573
+ Refresh gate matches the user-facing docs (`docs/commands/update.md`):
10574
+ `--check` refreshes when TTL has elapsed; `--force` bypasses the TTL
10575
+ gate to refresh even on a fresh cache. Synchronous refresh here also
10576
+ pre-empts the post-command background spawn — `_do_update_check`
10577
+ touches `update-check.last-fetch` first, so `_is_update_check_due`
10578
+ returns False by the time the hook runs.
10579
+ """
10580
+ config = load_config()
10581
+ if force or _is_update_check_due(config):
10582
+ _do_update_check()
10583
+ state = _load_update_state()
10584
+ if state is None:
10585
+ # Still nothing on disk — try once even with a fresh TTL marker
10586
+ # so the first invocation isn't a content-free "state unavailable".
10587
+ _do_update_check()
10588
+ state = _load_update_state()
10589
+ if state is None:
10590
+ if output_json:
10591
+ # Emit a parseable minimal envelope so JSON consumers always
10592
+ # get a payload; rc stays 0 (best-effort, matches the
10593
+ # `cmd_refresh_usage` precedent that network failures are
10594
+ # not user-actionable errors). Spec §4.4.
10595
+ print(
10596
+ json.dumps(_UPDATE_CHECK_JSON_UNAVAILABLE_ENVELOPE, indent=2)
10597
+ )
10598
+ return 0
10599
+ print("cctally update: state unavailable", file=sys.stderr)
10600
+ return 0
10601
+ suppress = _load_update_suppress()
10602
+ if output_json:
10603
+ print(json.dumps(_format_update_check_json(state, suppress), indent=2))
10604
+ else:
10605
+ print(_format_update_check_human(state, suppress))
10606
+ return 0
10607
+
10608
+
10609
+ def _preflight_install(method: InstallMethod, version: str | None) -> None:
10610
+ """Validate the install plan before any subprocess runs (spec §5.1).
10611
+
10612
+ Ordered checks (each raises and short-circuits the rest):
10613
+ 1. method != "unknown" — manual-fallback bucket per §2.4.
10614
+ 2. version (if not None) matches `_SEMVER_RE` — `X.Y.Z` or
10615
+ `X.Y.Z-prerelease`.
10616
+ 3. (method, version) compatibility — brew has no versioned
10617
+ formulae; pinned-version installs must be done manually.
10618
+ 4. npm-only: the `<prefix>/bin` directory must be writable; if
10619
+ not, surface the sudo / `npm config set prefix` recipes
10620
+ instead of letting npm fail with EACCES inside the run.
10621
+
10622
+ Raises :class:`UpdateValidationError` for input-validation failures
10623
+ (rc=2 at the boundary): invalid --version syntax, --version+brew
10624
+ combo. Raises :class:`UpdateError` for environment / runtime
10625
+ failures (rc=1 at the boundary): unknown install method, npm
10626
+ prefix not writable.
10627
+
10628
+ Brew preflight is intentionally a no-op beyond the version-combo
10629
+ check (codex review #2): homebrew installs into ``libexec/bin/``,
10630
+ so ``realpath`` lands inside the keg, not the brew bin prefix; brew
10631
+ has its own permission model and ``brew doctor`` is the diagnostic
10632
+ users already know.
10633
+ """
10634
+ if method.method == "unknown":
10635
+ raise UpdateError(
10636
+ "Install method is 'unknown' — automatic update unavailable.\n"
10637
+ "If you installed from source: cd <your cctally repo> && git pull && bin/symlink"
10638
+ )
10639
+ if version is not None and not _SEMVER_RE.match(version):
10640
+ raise UpdateValidationError(
10641
+ f"Invalid version: {version!r} (expected X.Y.Z or X.Y.Z-id.N)"
10642
+ )
10643
+ if method.method == "brew" and version is not None:
10644
+ raise UpdateValidationError(
10645
+ "Pinned-version install is not supported on Homebrew "
10646
+ "(no versioned formulae).\n"
10647
+ "To install a specific version manually:\n"
10648
+ f" brew uninstall cctally\n"
10649
+ f" brew install https://github.com/{PUBLIC_REPO}/releases/download/v{version}/cctally-{version}.tar.gz"
10650
+ )
10651
+ if method.method == "npm":
10652
+ prefix_bin = os.path.join(method.npm_prefix, "bin")
10653
+ if not os.access(prefix_bin, os.W_OK):
10654
+ raise UpdateError(
10655
+ f"npm prefix '{prefix_bin}' is not writable.\n"
10656
+ f"Run with sudo: sudo npm install -g cctally@{version or 'latest'}\n"
10657
+ "Or relocate: npm config set prefix ~/.npm-global"
10658
+ )
10659
+ # brew: NO preflight beyond the --version combo check above
10660
+ # (codex review #2 amendment to spec §5.1).
10661
+
10662
+
10663
+ def _build_update_steps(
10664
+ method: InstallMethod, version: str | None
10665
+ ) -> list[tuple[str, list[str]]]:
10666
+ """Build the ordered list of subprocess steps for an install plan.
10667
+
10668
+ Each step is ``(human_name, argv)`` where ``human_name`` is the
10669
+ label rendered in dry-run output and the dashboard live-stream
10670
+ modal, and ``argv`` is the list passed to ``subprocess.Popen`` (no
10671
+ shell). Brew is two steps (``brew update`` then ``brew upgrade
10672
+ cctally``) per spec §5.2 + Q6a — splitting them gives diagnostic
10673
+ clarity (a stale tap manifesting as a hung ``brew update`` is
10674
+ distinguishable from an ``upgrade`` failure). npm is one step.
10675
+ """
10676
+ if method.method == "brew":
10677
+ return [
10678
+ ("brew update", ["brew", "update", "--quiet"]),
10679
+ ("brew upgrade cctally", ["brew", "upgrade", "cctally"]),
10680
+ ]
10681
+ if method.method == "npm":
10682
+ target = f"cctally@{version}" if version else "cctally@latest"
10683
+ return [("npm install -g", ["npm", "install", "-g", target])]
10684
+ raise AssertionError(
10685
+ f"step builder called with method={method.method!r} "
10686
+ "(should have been rejected by _preflight_install)"
10687
+ )
10688
+
10689
+
10690
+ def _run_streaming(
10691
+ cmd: list[str],
10692
+ *,
10693
+ on_stdout: Callable[[str], None],
10694
+ on_stderr: Callable[[str], None],
10695
+ log_fd,
10696
+ ) -> int:
10697
+ """Run ``cmd``, line-buffer stdout/stderr to callbacks, append to log.
10698
+
10699
+ Two-thread pump (one per stream) → callbacks + log lines. Each log
10700
+ line is ``<iso-utc> <STREAM> <raw-line>`` so a grep for the stream
10701
+ label still recovers the chronological order even when stdout and
10702
+ stderr interleave at sub-line resolution. ``proc.wait()`` is the
10703
+ synchronization point; the pump threads are daemons so a crash in
10704
+ the parent doesn't leave them lingering.
10705
+
10706
+ Used by:
10707
+ - the CLI install path (Task 5; this file) where ``on_stdout`` /
10708
+ ``on_stderr`` print to the parent's stdout/stderr;
10709
+ - the dashboard ``UpdateWorker`` thread (Task 6) where the
10710
+ callbacks push lines into a per-stream ring buffer for SSE.
10711
+
10712
+ Stdin is inherited from the parent — the wrapped commands
10713
+ (``brew update``, ``npm install -g``) take no input; piping
10714
+ ``DEVNULL`` would just add a syscall.
10715
+ """
10716
+ proc = subprocess.Popen(
10717
+ cmd,
10718
+ stdout=subprocess.PIPE,
10719
+ stderr=subprocess.PIPE,
10720
+ bufsize=1,
10721
+ text=True,
10722
+ )
10723
+
10724
+ def pump(stream, cb, label):
10725
+ for line in stream:
10726
+ cb(line.rstrip("\n"))
10727
+ if log_fd is not None:
10728
+ log_fd.write(f"{_now_utc().isoformat()} {label} {line}")
10729
+ log_fd.flush()
10730
+ stream.close()
10731
+
10732
+ t_out = threading.Thread(
10733
+ target=pump, args=(proc.stdout, on_stdout, "STDOUT"), daemon=True
10734
+ )
10735
+ t_err = threading.Thread(
10736
+ target=pump, args=(proc.stderr, on_stderr, "STDERR"), daemon=True
10737
+ )
10738
+ t_out.start()
10739
+ t_err.start()
10740
+ proc.wait()
10741
+ t_out.join()
10742
+ t_err.join()
10743
+ return proc.returncode
10744
+
10745
+
10746
+ def _do_update_install(
10747
+ *, version: str | None, dry_run: bool, output_json: bool
10748
+ ) -> int:
10749
+ """`cctally update` (no mode flag) — install execution (spec §5).
10750
+
10751
+ Task-4 inline gates moved into :func:`_preflight_install`. Real
10752
+ install: acquire lock → log INSTALL_START → run each step (logging
10753
+ STEP_START/STEP_EXIT), bail on the first non-zero rc → log
10754
+ INSTALL_SUCCESS → release lock + rotate log in finally.
10755
+
10756
+ Dry-run path passes ``mutate=False`` to detection (codex review
10757
+ fix #4), prints "Would run: ..." (or one JSON-line per step) for
10758
+ each planned step, and exits 0 without touching the lock or
10759
+ running any subprocesses.
10760
+
10761
+ Raises :class:`UpdateError` (rc=1 at boundary) for unknown method
10762
+ / write-perm-denied; :class:`UpdateValidationError` (rc=2) for
10763
+ invalid --version / --version+brew. The boundary distinction is
10764
+ enforced by :func:`cmd_update`'s try/except below.
10765
+ """
10766
+ method = _detect_install_method(mutate=not dry_run)
10767
+ _preflight_install(method, version)
10768
+ steps = _build_update_steps(method, version)
10769
+ if dry_run:
10770
+ for name, cmd in steps:
10771
+ if output_json:
10772
+ print(json.dumps({"step": name, "would_run": cmd}))
10773
+ else:
10774
+ quoted = " ".join(shlex.quote(c) for c in cmd)
10775
+ print(f"Would run: {quoted}")
10776
+ return 0
10777
+ UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
10778
+ lock_fd = _acquire_update_lock()
10779
+ try:
10780
+ with open(UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
10781
+ _log_update_event(log_fd, "INSTALL_START", method=method.method)
10782
+ for step_name, cmd in steps:
10783
+ _log_update_event(log_fd, "STEP_START", name=step_name)
10784
+ rc = _run_streaming(
10785
+ cmd,
10786
+ on_stdout=lambda line: print(line, file=sys.stdout, flush=True),
10787
+ on_stderr=lambda line: print(line, file=sys.stderr, flush=True),
10788
+ log_fd=log_fd,
10789
+ )
10790
+ _log_update_event(log_fd, "STEP_EXIT", name=step_name, rc=rc)
10791
+ if rc != 0:
10792
+ return 1
10793
+ _log_update_event(log_fd, "INSTALL_SUCCESS")
10794
+ _stamp_install_success_to_state(version)
10795
+ return 0
10796
+ finally:
10797
+ _release_update_lock(lock_fd)
10798
+ _rotate_update_log_if_needed()
10799
+
10800
+
10801
+ # === Dashboard execvp re-entry (spec §5.7) ===
10802
+ # ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT are captured at dashboard
10803
+ # server boot in cmd_dashboard. _resolve_execvp_target uses them to
10804
+ # return (entrypoint, exec_argv) for os.execvp:
10805
+ #
10806
+ # - npm: entrypoint = <prefix>/bin/cctally → Node shim, which
10807
+ # re-resolves CCTALLY_PYTHON before re-spawning Python (so a custom
10808
+ # interpreter setting survives the restart).
10809
+ # - brew: entrypoint = <brew>/bin/cctally → symlink into the
10810
+ # post-upgrade Python script with its rewritten shebang.
10811
+ # - Fallback when shutil.which("cctally") returned None: use
10812
+ # sys.argv[0] directly. Loses the npm shim layer; we accept the
10813
+ # degraded edge case rather than guess.
10814
+ ORIGINAL_SYS_ARGV: list[str] = []
10815
+ ORIGINAL_ENTRYPOINT: "str | None" = None
10816
+
10817
+
10818
+ def _resolve_execvp_target() -> tuple[str, list[str]]:
10819
+ """Return (entrypoint, exec_argv) per spec §5.7.
10820
+
10821
+ Re-enters the npm shim by execvp'ing the PATH-resolved ``cctally``
10822
+ (Node shim for npm, brew symlink for brew). Falls back to
10823
+ ``sys.argv[0]`` only when ``shutil.which`` returned ``None`` at
10824
+ dashboard boot (rare absolute-path invocation).
10825
+ """
10826
+ if ORIGINAL_ENTRYPOINT is not None:
10827
+ return (
10828
+ ORIGINAL_ENTRYPOINT,
10829
+ [ORIGINAL_ENTRYPOINT, *ORIGINAL_SYS_ARGV[1:]],
10830
+ )
10831
+ return (ORIGINAL_SYS_ARGV[0], list(ORIGINAL_SYS_ARGV))
10832
+
10833
+
10834
+ class UpdateWorker:
10835
+ """Single-slot dashboard-side update orchestrator (spec §5.6).
10836
+
10837
+ A single instance lives on the dashboard server (created in
10838
+ cmd_dashboard, exposed as the module-level ``_UPDATE_WORKER``).
10839
+ ``start()`` returns ``(True, run_id)`` on accept and
10840
+ ``(False, current_run_id)`` when a run is already in progress —
10841
+ serializes concurrent button clicks without taking the install lock
10842
+ on the rejected path. ``_run`` runs preflight → lock → streamed
10843
+ steps → execvp on success / error_event on failure / done on
10844
+ non-zero subprocess exit. The ``released`` flag enforces the
10845
+ idempotent-release contract from spec §5.6.1: success path releases
10846
+ pre-execvp and skips the finally release; pre-execvp failure path
10847
+ releases in finally.
10848
+ """
10849
+
10850
+ def __init__(self) -> None:
10851
+ self._lock = threading.Lock()
10852
+ self._current_id: "str | None" = None
10853
+ # run_id -> queue.Queue of event dicts. Each subscriber drains
10854
+ # via ``stream(run_id)``; the worker thread enqueues via
10855
+ # ``_emit``. A single subscriber per run is the dashboard
10856
+ # contract; multi-subscriber broadcast is out of scope. The
10857
+ # producer (``_run``) intentionally does NOT pop its entry —
10858
+ # that would race a late consumer (#32): the worker thread can
10859
+ # complete its finally before the consumer enters ``stream()``,
10860
+ # leaving the consumer to look up a missing key. Cleanup
10861
+ # ownership now belongs to ``stream()``'s finally; if no
10862
+ # consumer ever subscribes, ``start()`` reaps stale entries on
10863
+ # the next run.
10864
+ self._streams: dict[str, "queue.Queue"] = {}
10865
+
10866
+ def start(self, version: "str | None") -> tuple[bool, str]:
10867
+ """Begin a run. Returns (accepted, run_id).
10868
+
10869
+ ``accepted=False`` when another run is in progress; the
10870
+ returned ``run_id`` is the in-progress one (so the caller can
10871
+ surface it as ``run_id_in_progress`` to the client).
10872
+ """
10873
+ with self._lock:
10874
+ if self._current_id is not None:
10875
+ return (False, self._current_id)
10876
+ # Reap any stale entries from prior no-consumer runs. Safe
10877
+ # under the lock: ``_current_id is None`` here, so no live
10878
+ # stream() generator holds a reference into the dict by
10879
+ # run_id (only by local-variable q ref, which survives the
10880
+ # pop).
10881
+ self._streams.clear()
10882
+ run_id = secrets.token_hex(8)
10883
+ self._current_id = run_id
10884
+ self._streams[run_id] = queue.Queue()
10885
+ threading.Thread(
10886
+ target=self._run, args=(run_id, version), daemon=True,
10887
+ name="cctally-update-worker",
10888
+ ).start()
10889
+ return (True, run_id)
10890
+
10891
+ def status(self) -> dict:
10892
+ """Return ``{"current_run_id": <run_id|None>}`` for /api/update/status."""
10893
+ with self._lock:
10894
+ return {"current_run_id": self._current_id}
10895
+
10896
+ def _emit(self, run_id: str, event: dict) -> None:
10897
+ q = self._streams.get(run_id)
10898
+ if q is not None:
10899
+ q.put(event)
10900
+
10901
+ def stream(self, run_id: str):
10902
+ """Generator yielding events for the given run_id.
10903
+
10904
+ Yields a ``{"type": "heartbeat"}`` event every 15 s of idle so
10905
+ the SSE proxy / EventSource keep-alive stays warm. Closes
10906
+ (returns) on the terminal events: ``execvp`` (success path),
10907
+ ``error_event`` (preflight or other UpdateError), ``done``
10908
+ (non-zero subprocess exit). Yields nothing and returns
10909
+ immediately for unknown run_ids — the HTTP handler then closes
10910
+ the SSE connection.
10911
+ """
10912
+ q = self._streams.get(run_id)
10913
+ if q is None:
10914
+ return
10915
+ try:
10916
+ while True:
10917
+ try:
10918
+ ev = q.get(timeout=15)
10919
+ except queue.Empty:
10920
+ yield {"type": "heartbeat"}
10921
+ continue
10922
+ yield ev
10923
+ if ev["type"] in ("execvp", "error_event", "done"):
10924
+ return
10925
+ finally:
10926
+ # Only reap when the worker is no longer the active producer
10927
+ # for this run_id. A mid-run modal close unwinds this
10928
+ # generator while ``_current_id == run_id`` and ``_run`` is
10929
+ # still emitting — popping here would silently drop those
10930
+ # events, and a modal reopen (slice.runId is preserved per
10931
+ # spec §6) would re-subscribe against a missing queue.
10932
+ # Cleanup still happens on the first ``stream()`` exit AFTER
10933
+ # the worker terminates (its finally clears _current_id), or
10934
+ # via ``start()``'s reap on the next run.
10935
+ with self._lock:
10936
+ if self._current_id != run_id:
10937
+ self._streams.pop(run_id, None)
10938
+
10939
+ def _run(self, run_id: str, version: "str | None") -> None:
10940
+ lock_fd = None
10941
+ released = False # idempotent-release guard per §5.6.1
10942
+ log_fd = None
10943
+ try:
10944
+ method = _detect_install_method(mutate=True)
10945
+ _preflight_install(method, version)
10946
+ UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
10947
+ lock_fd = _acquire_update_lock()
10948
+ log_fd = open(UPDATE_LOG_PATH, "a", encoding="utf-8")
10949
+ _log_update_event(log_fd, "INSTALL_START", method=method.method)
10950
+ for step_name, cmd in _build_update_steps(method, version):
10951
+ self._emit(run_id, {"type": "step", "name": step_name})
10952
+ _log_update_event(log_fd, "STEP_START", name=step_name)
10953
+ rc = _run_streaming(
10954
+ cmd,
10955
+ on_stdout=lambda line, rid=run_id: self._emit(
10956
+ rid, {"type": "stdout", "data": line}
10957
+ ),
10958
+ on_stderr=lambda line, rid=run_id: self._emit(
10959
+ rid, {"type": "stderr", "data": line}
10960
+ ),
10961
+ log_fd=log_fd,
10962
+ )
10963
+ _log_update_event(log_fd, "STEP_EXIT", name=step_name, rc=rc)
10964
+ self._emit(run_id, {"type": "exit", "rc": rc, "step": step_name})
10965
+ if rc != 0:
10966
+ self._emit(run_id, {"type": "done", "success": False})
10967
+ return
10968
+ _log_update_event(log_fd, "INSTALL_SUCCESS")
10969
+ _stamp_install_success_to_state(version)
10970
+ entrypoint, exec_argv = _resolve_execvp_target()
10971
+ self._emit(run_id, {"type": "execvp", "argv": exec_argv})
10972
+ try:
10973
+ log_fd.close()
10974
+ finally:
10975
+ log_fd = None
10976
+ # 0.5 s breathing room so the SSE pump flushes the final
10977
+ # ``execvp`` event to the browser before we hand the
10978
+ # process over to the new image. If the browser misses it
10979
+ # the polling fallback (/api/update/status) covers reentry.
10980
+ time.sleep(0.5)
10981
+ _release_update_lock(lock_fd)
10982
+ released = True
10983
+ os.execvp(entrypoint, exec_argv)
10984
+ except UpdateError as e:
10985
+ self._emit(run_id, {"type": "error_event", "message": str(e)})
10986
+ except Exception as e:
10987
+ self._emit(
10988
+ run_id, {"type": "error_event", "message": f"unexpected: {e!r}"}
10989
+ )
10990
+ finally:
10991
+ if log_fd is not None:
10992
+ try:
10993
+ log_fd.close()
10994
+ except Exception:
10995
+ pass
10996
+ if lock_fd is not None and not released:
10997
+ try:
10998
+ _release_update_lock(lock_fd)
10999
+ except Exception:
11000
+ pass
11001
+ with self._lock:
11002
+ self._current_id = None
11003
+ # _streams[run_id] intentionally retained — see class
11004
+ # docstring. Cleanup is owned by stream()'s finally;
11005
+ # start() sweeps stale entries on the next run.
11006
+
11007
+
11008
+ # Module-level singleton. Populated by cmd_dashboard on server boot;
11009
+ # consumed by DashboardHTTPHandler's /api/update* routes. Kept None
11010
+ # for non-dashboard subcommands so a stray test that loads the script
11011
+ # without booting the dashboard sees a clean uninitialized state.
11012
+ _UPDATE_WORKER: "UpdateWorker | None" = None
11013
+
11014
+
11015
+ class _DashboardUpdateCheckThread(threading.Thread):
11016
+ """Dedicated update-check polling thread (spec §3.5).
11017
+
11018
+ Independent of the data-sync thread so it runs even under
11019
+ ``--no-sync`` (codex review fix #5). Wakes once per
11020
+ :data:`UPDATE_DASHBOARD_CHECK_POLL_S` (30 min), consults
11021
+ :func:`_is_update_check_due`, runs :func:`_do_update_check` if so.
11022
+ The poll cadence is NOT the network-call frequency — actual TTL
11023
+ gate (default 24 h) lives in ``_is_update_check_due``. Disabling
11024
+ via ``update.check.enabled = false`` is honoured inside the gate
11025
+ so the thread becomes a no-op without needing teardown.
11026
+
11027
+ After a successful check, republishes the current snapshot via the
11028
+ SSE hub so long-open dashboard tabs in ``--no-sync`` mode pick up
11029
+ the fresh ``latest_version`` written to ``update-state.json``. The
11030
+ snapshot itself is unchanged — ``snapshot_to_envelope`` re-reads
11031
+ the state file per envelope build, so a bare publish is enough to
11032
+ refresh the badge for every live subscriber.
11033
+ """
11034
+
11035
+ daemon = True
11036
+
11037
+ def __init__(
11038
+ self,
11039
+ stop_event: "threading.Event",
11040
+ *,
11041
+ hub: "SSEHub | None" = None,
11042
+ snapshot_ref: "_SnapshotRef | None" = None,
11043
+ ) -> None:
11044
+ super().__init__(name="cctally-update-check")
11045
+ self._stop = stop_event
11046
+ self._hub = hub
11047
+ self._ref = snapshot_ref
11048
+
11049
+ def run(self) -> None:
11050
+ while not self._stop.is_set():
11051
+ try:
11052
+ config = load_config()
11053
+ if _is_update_check_due(config):
11054
+ _do_update_check()
11055
+ if self._hub is not None and self._ref is not None:
11056
+ snap = self._ref.get()
11057
+ if snap is not None:
11058
+ self._hub.publish(snap)
11059
+ except Exception as e:
11060
+ # Log but never propagate — this thread must keep
11061
+ # ticking so a transient registry hiccup doesn't
11062
+ # silently disable the polling cadence for the rest
11063
+ # of the dashboard's lifetime.
11064
+ try:
11065
+ UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
11066
+ with open(UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
11067
+ _log_update_event(
11068
+ log_fd, "CHECK_FAILED", error=str(e)[:200]
11069
+ )
11070
+ except Exception:
11071
+ pass
11072
+ self._stop.wait(UPDATE_DASHBOARD_CHECK_POLL_S)
11073
+
11074
+
11075
+ def cmd_update(args) -> int:
11076
+ """`cctally update` entry point — routes by mode flag (spec §4.1)."""
11077
+ skip_arg = getattr(args, "skip", None)
11078
+ remind_arg = getattr(args, "remind_later", None)
11079
+ check_arg = getattr(args, "check", False)
11080
+ # NOTE: `args.install_version`, not `args.version` — the subparser's
11081
+ # `--version X.Y.Z` is `dest="install_version"` to avoid colliding
11082
+ # with the top-level `--version` flag handled in `main()`.
11083
+ version_arg = getattr(args, "install_version", None)
11084
+ modes = sum(bool(x) for x in [
11085
+ check_arg,
11086
+ skip_arg is not None,
11087
+ remind_arg is not None,
11088
+ ])
11089
+ if modes > 1:
11090
+ print(
11091
+ "cctally update: --check / --skip / --remind-later are "
11092
+ "mutually exclusive",
11093
+ file=sys.stderr,
11094
+ )
11095
+ return 2
11096
+ if version_arg is not None and (
11097
+ check_arg or skip_arg is not None or remind_arg is not None
11098
+ ):
11099
+ print(
11100
+ "cctally update: --version is install-mode only",
11101
+ file=sys.stderr,
11102
+ )
11103
+ return 2
11104
+ if skip_arg is not None:
11105
+ return _do_update_skip(skip_arg)
11106
+ if remind_arg is not None:
11107
+ return _do_update_remind_later(remind_arg)
11108
+ if check_arg:
11109
+ return _do_update_check_user(
11110
+ force=getattr(args, "force", False),
11111
+ output_json=getattr(args, "json", False),
11112
+ )
11113
+ try:
11114
+ return _do_update_install(
11115
+ version=version_arg,
11116
+ dry_run=getattr(args, "dry_run", False),
11117
+ output_json=getattr(args, "json", False),
11118
+ )
11119
+ except UpdateValidationError as e:
11120
+ # Input validation failure (invalid --version syntax,
11121
+ # --version+brew combo). rc=2 preserves the Task-4 contract.
11122
+ print(f"cctally update: {e}", file=sys.stderr)
11123
+ return 2
11124
+ except UpdateError as e:
11125
+ # Runtime / environment failure (unknown install method, npm
11126
+ # prefix not writable, lock contention). rc=1.
11127
+ print(f"cctally update: {e}", file=sys.stderr)
11128
+ return 1
11129
+
11130
+
9389
11131
  def get_week_start_name(config: dict[str, Any], override: str | None = None) -> str:
9390
11132
  if override:
9391
11133
  name = override.strip().lower()
@@ -13353,6 +15095,97 @@ def _args_emit_machine_stdout(args: argparse.Namespace) -> bool:
13353
15095
  return bool(getattr(args, "status_line", False))
13354
15096
 
13355
15097
 
15098
+ # Update-banner suppression set — parallel to ``_BANNER_SUPPRESSED_COMMANDS``
15099
+ # (migration banner) but with its own membership. ``update`` itself shouldn't
15100
+ # advertise an update; ``_update-check`` is the detached-refresh worker
15101
+ # (silent by contract). Other suppressions (record-usage, hook-tick, sync-week,
15102
+ # cache-sync, refresh-usage, tui, db) ride the existing migration set so
15103
+ # the two banners stay aligned for those commands.
15104
+ _UPDATE_BANNER_EXTRA_SUPPRESSED = frozenset({"_update-check", "update"})
15105
+
15106
+
15107
+ def _semver_gt(a: str, b: str) -> bool:
15108
+ """SemVer comparison via :func:`_release_parse_semver` + the
15109
+ SemVer-§11.4-aware sort key. ``a > b`` returns True when ``a`` is
15110
+ a strictly higher version. Raises :class:`ValueError` on either
15111
+ input being malformed (callers wrap in try/except)."""
15112
+ return _release_semver_sort_key(_release_parse_semver(a)) > \
15113
+ _release_semver_sort_key(_release_parse_semver(b))
15114
+
15115
+
15116
+ def _should_show_update_banner(
15117
+ command: str | None,
15118
+ args: argparse.Namespace,
15119
+ state: dict[str, Any] | None,
15120
+ suppress: dict[str, Any],
15121
+ config: dict[str, Any],
15122
+ ) -> bool:
15123
+ """Return True iff a one-line update banner should land on stderr
15124
+ after this command's output (spec §4.2).
15125
+
15126
+ Composition is the key invariant: the predicate **must** delegate
15127
+ machine-mode detection to the existing helpers
15128
+ (:func:`_args_emit_json`, :func:`_args_emit_machine_stdout`) so a
15129
+ new ``--json`` dest variant or status-line flag added to any
15130
+ subcommand inherits the suppression automatically. Adding a parallel
15131
+ list here would silently regress that invariant — the spec
15132
+ amendment for Codex finding #8 codifies this.
15133
+ """
15134
+ if command in _BANNER_SUPPRESSED_COMMANDS or command in _UPDATE_BANNER_EXTRA_SUPPRESSED:
15135
+ return False
15136
+ if _args_emit_json(args):
15137
+ return False
15138
+ if _args_emit_machine_stdout(args):
15139
+ return False
15140
+ if getattr(args, "format", None) is not None:
15141
+ return False
15142
+ if not sys.stderr.isatty():
15143
+ return False
15144
+ if not config.get("update", {}).get("check", {}).get("enabled", True):
15145
+ return False
15146
+ if state is None:
15147
+ return False
15148
+ cur = state.get("current_version")
15149
+ lat = state.get("latest_version")
15150
+ if not cur or not lat:
15151
+ return False
15152
+ try:
15153
+ if not _semver_gt(lat, cur):
15154
+ return False
15155
+ except ValueError:
15156
+ return False
15157
+ if lat in suppress.get("skipped_versions", []):
15158
+ return False
15159
+ remind = suppress.get("remind_after")
15160
+ if remind is not None:
15161
+ try:
15162
+ # Hide while the deferral is active AND the user-pinned version
15163
+ # is still the latest. A newer drop overrides the deferral.
15164
+ if not _semver_gt(lat, remind["version"]):
15165
+ until = dt.datetime.fromisoformat(remind["until_utc"])
15166
+ if _now_utc() < until:
15167
+ return False
15168
+ except (KeyError, ValueError):
15169
+ # Malformed remind_after: fail-open (banner shows). Better
15170
+ # than silently dropping a real update reminder.
15171
+ pass
15172
+ return True
15173
+
15174
+
15175
+ def _format_update_banner(state: dict[str, Any]) -> str:
15176
+ """One-line stderr banner. Spec §4.2.
15177
+
15178
+ Includes the dismissal recipe inline so the user never has to
15179
+ consult docs to silence it.
15180
+ """
15181
+ cur = state["current_version"]
15182
+ lat = state["latest_version"]
15183
+ return (
15184
+ f"↑ cctally {lat} available (you're on {cur}). "
15185
+ f"Run `cctally update`. Skip: cctally update --skip {lat}"
15186
+ )
15187
+
15188
+
13356
15189
  def _print_migration_error_banner_if_needed(args) -> None:
13357
15190
  """Print a one-line warning banner if the migration error log has
13358
15191
  entries.
@@ -14339,7 +16172,9 @@ def cmd_daily(args: argparse.Namespace) -> int:
14339
16172
  args._resolved_tz = tz
14340
16173
 
14341
16174
  range = _parse_cli_date_range(
14342
- args, tz_name=(tz.key if tz is not None else None)
16175
+ args,
16176
+ tz_name=(tz.key if tz is not None else None),
16177
+ now_utc=_command_as_of(),
14343
16178
  )
14344
16179
  if isinstance(range, int):
14345
16180
  return range
@@ -14407,7 +16242,9 @@ def cmd_monthly(args: argparse.Namespace) -> int:
14407
16242
  args._resolved_tz = tz
14408
16243
 
14409
16244
  range = _parse_cli_date_range(
14410
- args, tz_name=(tz.key if tz is not None else None)
16245
+ args,
16246
+ tz_name=(tz.key if tz is not None else None),
16247
+ now_utc=_command_as_of(),
14411
16248
  )
14412
16249
  if isinstance(range, int):
14413
16250
  return range
@@ -14602,7 +16439,9 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
14602
16439
  # (because resolve_display_tz returns None for canonical "local").
14603
16440
  tz_name = _resolve_codex_tz_name(args, config)
14604
16441
  force_compact = bool(getattr(args, "compact", False))
14605
- range = _parse_cli_date_range(args, tz_name=tz_name)
16442
+ range = _parse_cli_date_range(
16443
+ args, tz_name=tz_name, now_utc=_command_as_of(),
16444
+ )
14606
16445
  if isinstance(range, int):
14607
16446
  return range
14608
16447
  range_start, range_end = range
@@ -14652,7 +16491,9 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
14652
16491
  # F2 fix: see cmd_codex_daily.
14653
16492
  tz_name = _resolve_codex_tz_name(args, config)
14654
16493
  force_compact = bool(getattr(args, "compact", False))
14655
- range = _parse_cli_date_range(args, tz_name=tz_name)
16494
+ range = _parse_cli_date_range(
16495
+ args, tz_name=tz_name, now_utc=_command_as_of(),
16496
+ )
14656
16497
  if isinstance(range, int):
14657
16498
  return range
14658
16499
  range_start, range_end = range
@@ -14760,7 +16601,9 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
14760
16601
  # F2 fix: see cmd_codex_daily.
14761
16602
  tz_name = _resolve_codex_tz_name(args, config)
14762
16603
  force_compact = bool(getattr(args, "compact", False))
14763
- range = _parse_cli_date_range(args, tz_name=tz_name)
16604
+ range = _parse_cli_date_range(
16605
+ args, tz_name=tz_name, now_utc=_command_as_of(),
16606
+ )
14764
16607
  if isinstance(range, int):
14765
16608
  return range
14766
16609
  range_start, range_end = range
@@ -14806,6 +16649,31 @@ def _command_as_of() -> dt.datetime:
14806
16649
  return dt.datetime.now(dt.timezone.utc)
14807
16650
 
14808
16651
 
16652
+ def _now_utc() -> dt.datetime:
16653
+ """UTC now, with CCTALLY_AS_OF env override for fixture-stability.
16654
+
16655
+ Single time source for the `update` subcommand and its supporting
16656
+ state machine (TTL gates, ``remind_after.until_utc`` comparisons,
16657
+ log timestamps, install-method detection cache). Mirrors the
16658
+ documented CCTALLY_AS_OF precedent (see CLAUDE.md — `project` has
16659
+ a hidden `CCTALLY_AS_OF` env hook, and `_command_as_of` /
16660
+ `_share_now_utc` reuse it for `weekly`/`forecast`/share-render).
16661
+ Accepts ISO-8601 with `Z` or explicit offset; result is always
16662
+ tz-aware UTC.
16663
+
16664
+ Raises ValueError on malformed CCTALLY_AS_OF — deliberate fail-loud
16665
+ for the dev hook so fixture authors notice typos immediately rather
16666
+ than silently falling back to wall-clock time.
16667
+ """
16668
+ override = os.environ.get("CCTALLY_AS_OF")
16669
+ if override:
16670
+ override = override.strip()
16671
+ if override.endswith("Z"):
16672
+ override = override[:-1] + "+00:00"
16673
+ return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
16674
+ return dt.datetime.now(dt.timezone.utc)
16675
+
16676
+
14809
16677
  def _load_week_snapshots(
14810
16678
  since: dt.datetime, until: dt.datetime
14811
16679
  ) -> dict[dt.datetime, float]:
@@ -15073,7 +16941,7 @@ def cmd_project(args: argparse.Namespace) -> int:
15073
16941
 
15074
16942
  # Resolve [since_dt, until_dt] in UTC.
15075
16943
  if args.since or args.until:
15076
- parsed = _parse_cli_date_range(args)
16944
+ parsed = _parse_cli_date_range(args, now_utc=now)
15077
16945
  if isinstance(parsed, int):
15078
16946
  return parsed
15079
16947
  since_dt, until_dt = parsed
@@ -17011,7 +18879,9 @@ def cmd_session(args: argparse.Namespace) -> int:
17011
18879
  args._resolved_tz = tz
17012
18880
 
17013
18881
  range = _parse_cli_date_range(
17014
- args, tz_name=(tz.key if tz is not None else None)
18882
+ args,
18883
+ tz_name=(tz.key if tz is not None else None),
18884
+ now_utc=_command_as_of(),
17015
18885
  )
17016
18886
  if isinstance(range, int):
17017
18887
  return range
@@ -19569,12 +21439,48 @@ def cmd_five_hour_breakdown(args: argparse.Namespace) -> int:
19569
21439
  conn.close()
19570
21440
 
19571
21441
 
21442
+ # Decimal places retained when normalizing incoming percent floats.
21443
+ # Anthropic's `rate_limits.{5h,seven_day}.utilization` is computed as
21444
+ # `tokens / cap * 100` server-side and can land one ULP off the rational
21445
+ # answer (canonical sighting: `0.07 * 100 == 7.000000000000001`). 10dp
21446
+ # is well below any meaningful consumer precision (HWM comparisons
21447
+ # clamp at 1dp; JSON exports round to 3dp; floor-snap uses 1e-9) but
21448
+ # above IEEE 754 ULP scale near 100, so the round flushes the noise
21449
+ # without distorting any meaningful value.
21450
+ _PERCENT_NORMALIZE_DECIMALS = 10
21451
+
21452
+
21453
+ def _normalize_percent(value: "float | int | None") -> "float | None":
21454
+ """Flush IEEE 754 ULP noise out of an ingress percent value.
21455
+
21456
+ Single chokepoint applied at every site where a raw percent enters
21457
+ cctally's runtime path (OAuth fetch, hook-tick OAuth refresh, and
21458
+ the cmd_record_usage CLI ingress). Downstream consumers — HWM
21459
+ files, ``weekly_usage_snapshots.{weekly,five_hour}_percent`` REAL
21460
+ columns, ``five_hour_blocks.final_five_hour_percent``, milestone
21461
+ crossing values, and the SSE envelope's ``used_percent`` field —
21462
+ all read the cleaned value, so a single round here stops
21463
+ ``5h=7.000000000000001`` style strings from reaching any log or
21464
+ serialized surface.
21465
+
21466
+ ``None`` is the canonical absent-percent sentinel; preserve it
21467
+ unchanged so the optional-5h branches stay simple.
21468
+ """
21469
+ if value is None:
21470
+ return None
21471
+ return round(float(value), _PERCENT_NORMALIZE_DECIMALS)
21472
+
21473
+
19572
21474
  def cmd_record_usage(args: argparse.Namespace) -> int:
19573
21475
  """Record usage data from Claude Code status line rate_limits."""
19574
21476
  config = load_config()
19575
21477
  week_start_name = get_week_start_name(config, getattr(args, "week_start_name", None))
19576
21478
 
19577
- weekly_percent = float(args.percent)
21479
+ # ULP-noise sanitization is applied at the cmd_record_usage ingress
21480
+ # boundary so every downstream consumer (HWM files, DB rows,
21481
+ # five_hour_blocks rollup, milestones) reads a stable value. See
21482
+ # `_normalize_percent` for the rationale.
21483
+ weekly_percent = _normalize_percent(args.percent)
19578
21484
  resets_at = int(args.resets_at)
19579
21485
 
19580
21486
  five_hour_percent: float | None = None
@@ -19582,7 +21488,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
19582
21488
  five_hour_window_key: int | None = None
19583
21489
  five_hour_resets_at_epoch: int | None = None
19584
21490
  if args.five_hour_percent is not None:
19585
- five_hour_percent = float(args.five_hour_percent)
21491
+ five_hour_percent = _normalize_percent(args.five_hour_percent)
19586
21492
  if args.five_hour_resets_at is not None:
19587
21493
  five_hour_resets_at_epoch = int(args.five_hour_resets_at)
19588
21494
  five_hour_resets_at_str = dt.datetime.fromtimestamp(
@@ -20137,6 +22043,59 @@ def _settings_merge_uninstall(settings: dict) -> tuple[dict, int]:
20137
22043
  return settings, removed
20138
22044
 
20139
22045
 
22046
+ def _settings_merge_unwire_legacy(settings: dict) -> tuple[dict, int]:
22047
+ """Remove legacy-bespoke hook entries from ``settings`` in place.
22048
+
22049
+ Mirrors _settings_merge_uninstall's structure but matches against
22050
+ the legacy command set rather than the cctally one. Returns
22051
+ (mutated_settings, removed_count). Empty event lists are dropped.
22052
+ Trailing '&' is stripped before tokenizing so legacy installs that
22053
+ background the daemon-start hook still match.
22054
+ """
22055
+ import shlex as _shlex
22056
+ canonical = {(ev, tuple(_shlex.split(cmd))) for ev, cmd in _LEGACY_BESPOKE_COMMANDS}
22057
+ canonical_raw = {(ev, cmd) for ev, cmd in _LEGACY_BESPOKE_COMMANDS}
22058
+ hooks_root = settings.get("hooks")
22059
+ if not isinstance(hooks_root, dict):
22060
+ return settings, 0
22061
+ removed = 0
22062
+ for ev in [k for k in hooks_root.keys()]: # snapshot keys; we may del
22063
+ lst = hooks_root.get(ev)
22064
+ if not isinstance(lst, list):
22065
+ continue
22066
+ new_list: list = []
22067
+ for grp in lst:
22068
+ if not isinstance(grp, dict):
22069
+ new_list.append(grp)
22070
+ continue
22071
+ inner = grp.get("hooks", [])
22072
+ if not isinstance(inner, list):
22073
+ new_list.append(grp)
22074
+ continue
22075
+ kept_inner = []
22076
+ for h in inner:
22077
+ cmd = h.get("command", "") if isinstance(h, dict) else ""
22078
+ stripped = cmd.strip().rstrip("&").strip() if isinstance(cmd, str) else ""
22079
+ try:
22080
+ tokens = tuple(_shlex.split(stripped))
22081
+ except ValueError:
22082
+ kept_inner.append(h)
22083
+ continue
22084
+ if (ev, tokens) in canonical or (ev, stripped) in canonical_raw:
22085
+ removed += 1
22086
+ continue
22087
+ kept_inner.append(h)
22088
+ if kept_inner:
22089
+ grp["hooks"] = kept_inner
22090
+ new_list.append(grp)
22091
+ # else: matcher group's only entry was ours → drop the group
22092
+ if new_list:
22093
+ hooks_root[ev] = new_list
22094
+ else:
22095
+ del hooks_root[ev]
22096
+ return settings, removed
22097
+
22098
+
20140
22099
  def _hook_tick_log_line(line: str) -> None:
20141
22100
  """Append one line to hook-tick.log; create dir if missing.
20142
22101
 
@@ -20329,7 +22288,11 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
20329
22288
 
20330
22289
  seven = api.get("seven_day") or {}
20331
22290
  try:
20332
- seven_pct = float(seven["utilization"])
22291
+ # Normalize at the OAuth ingress so the payload JSON published
22292
+ # on the SSE envelope (`used_percent` field) is clean even when
22293
+ # this code path doesn't reach cmd_record_usage (e.g. payload
22294
+ # built then a downstream cmd_record_usage call fails).
22295
+ seven_pct = _normalize_percent(float(seven["utilization"]))
20333
22296
  seven_resets_iso = seven["resets_at"]
20334
22297
  seven_resets_epoch = _iso_to_epoch(seven_resets_iso)
20335
22298
  except (TypeError, ValueError, KeyError) as exc:
@@ -20345,7 +22308,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
20345
22308
  warnings: list = []
20346
22309
  if five is not None and "utilization" in five and "resets_at" in five:
20347
22310
  try:
20348
- five_pct = float(five["utilization"])
22311
+ five_pct = _normalize_percent(float(five["utilization"]))
20349
22312
  five_resets_iso = five["resets_at"]
20350
22313
  five_resets_epoch = _iso_to_epoch(five_resets_iso)
20351
22314
  except (TypeError, ValueError) as exc:
@@ -20555,7 +22518,7 @@ def _hook_tick_oauth_refresh(
20555
22518
  return "err(parse)", None
20556
22519
  seven = api["seven_day"]
20557
22520
  try:
20558
- seven_pct = float(seven["utilization"])
22521
+ seven_pct = _normalize_percent(float(seven["utilization"]))
20559
22522
  seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
20560
22523
  except (TypeError, ValueError, KeyError):
20561
22524
  return "err(parse)", None
@@ -20564,7 +22527,7 @@ def _hook_tick_oauth_refresh(
20564
22527
  five_resets_epoch: int | None = None
20565
22528
  if five is not None and "utilization" in five and "resets_at" in five:
20566
22529
  try:
20567
- five_pct = float(five["utilization"])
22530
+ five_pct = _normalize_percent(float(five["utilization"]))
20568
22531
  five_resets_epoch = _iso_to_epoch(five["resets_at"])
20569
22532
  except (TypeError, ValueError):
20570
22533
  five_pct = None
@@ -21731,6 +23694,25 @@ def _setup_shell_rc_hint() -> str:
21731
23694
  return "your shell rc"
21732
23695
 
21733
23696
 
23697
+ # Legacy bespoke hook set — see docs/superpowers/specs/2026-05-09-auto-migrate-legacy-hooks-design.md
23698
+ _LEGACY_BESPOKE_HOOKS_DIR = pathlib.Path.home() / ".claude" / "hooks"
23699
+ _LEGACY_BESPOKE_COMMANDS: tuple[tuple[str, str], ...] = (
23700
+ ("Stop", "python3 ~/.claude/hooks/record-usage-stop.py"),
23701
+ ("SubagentStart", "python3 ~/.claude/hooks/usage-poller-start.py"),
23702
+ ("SubagentStop", "python3 ~/.claude/hooks/usage-poller-stop.py"),
23703
+ )
23704
+ _LEGACY_BESPOKE_FILENAMES: tuple[str, ...] = (
23705
+ "record-usage-stop.py",
23706
+ "usage-poller-start.py",
23707
+ "usage-poller-stop.py",
23708
+ "usage-poller.py",
23709
+ )
23710
+ _LEGACY_POLLER_PID_FILE = pathlib.Path("/tmp/claude-usage-poller.pid")
23711
+ _LEGACY_POLLER_COUNT_FILE = pathlib.Path("/tmp/claude-usage-poller.count")
23712
+ _LEGACY_BACKUP_DIR_PREFIX = "cctally-legacy-hook-backup-"
23713
+ _LEGACY_POLLER_SIGTERM_GRACE_S = 0.250
23714
+
23715
+
21734
23716
  def _setup_detect_legacy_snippet() -> tuple[pathlib.Path, list[int]] | None:
21735
23717
  """Return (path, [line_numbers]) of the first file containing the snippet, or None."""
21736
23718
  for path in LEGACY_STATUSLINE_PATHS:
@@ -21746,6 +23728,314 @@ def _setup_detect_legacy_snippet() -> tuple[pathlib.Path, list[int]] | None:
21746
23728
  return None
21747
23729
 
21748
23730
 
23731
+ def _setup_detect_legacy_bespoke_hooks(settings: dict) -> dict:
23732
+ """Detect legacy bespoke hook state per spec Section 1.
23733
+
23734
+ Detection fires when ANY of the 3 canonical settings.json command
23735
+ strings matches an installed entry, OR ANY of the 4 canonical
23736
+ .py files exists at its canonical path under _LEGACY_BESPOKE_HOOKS_DIR.
23737
+
23738
+ Returns a dict with keys:
23739
+ detected: bool
23740
+ settings_entries: list of {"event": str, "command": str}
23741
+ files: list of str (rendered with ~/.claude/hooks/ prefix)
23742
+ """
23743
+ import shlex as _shlex
23744
+ canonical_cmds = {(ev, cmd) for ev, cmd in _LEGACY_BESPOKE_COMMANDS}
23745
+ canonical_tokens = {(ev, tuple(_shlex.split(cmd))) for ev, cmd in _LEGACY_BESPOKE_COMMANDS}
23746
+
23747
+ found_entries: list[dict] = []
23748
+ hooks_root = settings.get("hooks", {}) if isinstance(settings, dict) else {}
23749
+ if isinstance(hooks_root, dict):
23750
+ for event, lst in hooks_root.items():
23751
+ if not isinstance(lst, list):
23752
+ continue
23753
+ matched_for_this_event = False
23754
+ for grp in lst:
23755
+ if matched_for_this_event:
23756
+ break # already recorded one row for this event; don't double-count
23757
+ if not isinstance(grp, dict):
23758
+ continue
23759
+ inner = grp.get("hooks", [])
23760
+ if not isinstance(inner, list):
23761
+ # Mirrors the unwire helper's defensive guard: malformed
23762
+ # `hooks` value (None / int / dict) must not crash iteration.
23763
+ continue
23764
+ for h in inner:
23765
+ if not isinstance(h, dict):
23766
+ continue
23767
+ raw = h.get("command", "")
23768
+ if not isinstance(raw, str):
23769
+ continue
23770
+ stripped = raw.strip().rstrip("&").strip()
23771
+ try:
23772
+ tokens = tuple(_shlex.split(stripped))
23773
+ except ValueError:
23774
+ continue
23775
+ if (event, tokens) in canonical_tokens or (event, stripped) in canonical_cmds:
23776
+ # Record the canonical (clean) form for stable JSON output,
23777
+ # not the user's possibly-decorated raw command.
23778
+ clean_cmd = next(
23779
+ cmd for ev, cmd in _LEGACY_BESPOKE_COMMANDS if ev == event
23780
+ )
23781
+ found_entries.append({"event": event, "command": clean_cmd})
23782
+ matched_for_this_event = True
23783
+ break # one entry per matcher group is enough
23784
+
23785
+ found_files: list[str] = []
23786
+ for name in _LEGACY_BESPOKE_FILENAMES:
23787
+ p = _LEGACY_BESPOKE_HOOKS_DIR / name
23788
+ if p.exists():
23789
+ # Render with the ~ prefix the spec uses for stable JSON.
23790
+ found_files.append(f"~/.claude/hooks/{name}")
23791
+
23792
+ return {
23793
+ "detected": bool(found_entries) or bool(found_files),
23794
+ "settings_entries": found_entries,
23795
+ "files": found_files,
23796
+ }
23797
+
23798
+
23799
+ def _legacy_resolve_backup_dir() -> pathlib.Path:
23800
+ """Return ~/.claude/cctally-legacy-hook-backup-<UTC YYYYMMDD-HHMMSS>/.
23801
+
23802
+ Honors CCTALLY_AS_OF for fixture stability via _command_as_of(). Created
23803
+ on demand. Idempotent within the same wall-second (mkdir(exist_ok=True)).
23804
+
23805
+ See spec Section 1 ("What gets touched on accept" → step 2) and
23806
+ Section 2 ("Sequence position", step 6a). Backup dir is timestamped
23807
+ so a re-run never overwrites a prior migration's snapshot.
23808
+ """
23809
+ now = _command_as_of()
23810
+ stamp = now.strftime("%Y%m%d-%H%M%S")
23811
+ base = pathlib.Path.home() / ".claude" / f"{_LEGACY_BACKUP_DIR_PREFIX}{stamp}"
23812
+ base.mkdir(parents=True, exist_ok=True)
23813
+ return base
23814
+
23815
+
23816
+ def _legacy_move_files_to_backup(backup_dir: pathlib.Path) -> list[pathlib.Path]:
23817
+ """Move present canonical .py files from `_LEGACY_BESPOKE_HOOKS_DIR` into backup_dir.
23818
+
23819
+ Each canonical filename is moved only if present at its canonical path;
23820
+ missing files are silent no-ops (per spec Section 1: "Missing files are
23821
+ silent no-ops in the move loop"). Returns the list of destination paths
23822
+ actually written, in canonical (`_LEGACY_BESPOKE_FILENAMES`) order.
23823
+
23824
+ Uses `shutil.move` (canonical Python idiom): same-filesystem renames
23825
+ go through `os.rename`, cross-device moves fall through to
23826
+ `copy2 + unlink` atomically — and a failure on the unlink leg raises
23827
+ `OSError` instead of silently leaving a duplicate at both src and dst
23828
+ (which the prior hand-rolled try/except/inner-try did).
23829
+ """
23830
+ moved: list[pathlib.Path] = []
23831
+ for name in _LEGACY_BESPOKE_FILENAMES:
23832
+ src = _LEGACY_BESPOKE_HOOKS_DIR / name
23833
+ if not src.exists():
23834
+ continue
23835
+ dst = backup_dir / name
23836
+ try:
23837
+ shutil.move(str(src), str(dst))
23838
+ except OSError:
23839
+ # Best-effort: a failed move is silent; spec Section 1 step 2
23840
+ # treats the move loop's failures as no-ops (the daemon-stop
23841
+ # follow-up handles user-facing damage control).
23842
+ continue
23843
+ moved.append(dst)
23844
+ return moved
23845
+
23846
+
23847
+ def _legacy_stop_active_poller() -> str:
23848
+ """Best-effort SIGTERM (then SIGKILL) the bespoke daemon if alive.
23849
+
23850
+ Per spec Section 1 step 3: read /tmp/claude-usage-poller.pid, send
23851
+ SIGTERM, wait `_LEGACY_POLLER_SIGTERM_GRACE_S`, send SIGKILL if still
23852
+ alive. All steps are best-effort and silent on failure — the daemon
23853
+ may already be dead, the PID may be stale, the rlimits may forbid
23854
+ signaling, or the file may simply be absent.
23855
+
23856
+ Returns one of:
23857
+ "no-pid-file" — no /tmp/claude-usage-poller.pid present
23858
+ "stale-pid" — PID file exists but the PID isn't a live
23859
+ process, parse failed, OR the live PID's
23860
+ cmdline doesn't reference usage-poller.py
23861
+ (collapsed: don't signal an unrelated process)
23862
+ "sigterm-took" — SIGTERM landed and the process exited within
23863
+ the grace window
23864
+ "sigkill-took" — SIGTERM did not stop it; SIGKILL landed
23865
+ "permission-denied" — kernel refused to signal the PID (EPERM)
23866
+ """
23867
+ import signal as _signal
23868
+
23869
+ if not _LEGACY_POLLER_PID_FILE.exists():
23870
+ return "no-pid-file"
23871
+ try:
23872
+ raw = _LEGACY_POLLER_PID_FILE.read_text(encoding="utf-8", errors="replace").strip()
23873
+ pid = int(raw)
23874
+ except (OSError, ValueError):
23875
+ # Unreadable or non-numeric content → treat as stale (a corrupted
23876
+ # PID file is functionally indistinguishable from a stale one;
23877
+ # the cleanup helper will unlink it next).
23878
+ return "stale-pid"
23879
+
23880
+ # Aliveness probe: signal 0 doesn't deliver but does the permission
23881
+ # + existence check. ProcessLookupError → stale; PermissionError →
23882
+ # we'd fail the actual signal too, surface that distinctly.
23883
+ try:
23884
+ os.kill(pid, 0)
23885
+ except ProcessLookupError:
23886
+ return "stale-pid"
23887
+ except PermissionError:
23888
+ return "permission-denied"
23889
+ except OSError:
23890
+ return "stale-pid"
23891
+
23892
+ # Ownership probe: the PID file is at a predictable /tmp path that
23893
+ # outlives the daemon on uncleanly exit, and macOS PIDs cycle in a
23894
+ # narrow space — verify the live process is actually our legacy
23895
+ # poller before signaling. ps's `-o command=` emits the full cmdline
23896
+ # with no header on both macOS BSD ps and Linux util-linux ps.
23897
+ try:
23898
+ probe = subprocess.run(
23899
+ ["ps", "-p", str(pid), "-o", "command="],
23900
+ capture_output=True, text=True, timeout=2.0,
23901
+ )
23902
+ except (OSError, subprocess.TimeoutExpired):
23903
+ # Can't verify → don't signal. Treat as stale: a corrupted /tmp
23904
+ # sentinel is functionally equivalent to a missing process here.
23905
+ return "stale-pid"
23906
+ if probe.returncode != 0 or "usage-poller.py" not in probe.stdout:
23907
+ return "stale-pid"
23908
+
23909
+ # Process is alive AND owned by the legacy poller. SIGTERM, then poll
23910
+ # for exit within the grace.
23911
+ try:
23912
+ os.kill(pid, _signal.SIGTERM)
23913
+ except ProcessLookupError:
23914
+ # Race: process exited between probe and signal — treat as success.
23915
+ return "sigterm-took"
23916
+ except PermissionError:
23917
+ return "permission-denied"
23918
+ except OSError:
23919
+ # Residual OSError after ProcessLookupError/PermissionError are caught
23920
+ # specifically — exotic kernel refusal (ENOMEM during signal queueing,
23921
+ # LSM denial, etc.). Map to permission-denied: spec contract forbids
23922
+ # raising, and "we couldn't deliver the signal" is the closest existing
23923
+ # outcome.
23924
+ return "permission-denied"
23925
+
23926
+ deadline = time.monotonic() + _LEGACY_POLLER_SIGTERM_GRACE_S
23927
+ while time.monotonic() < deadline:
23928
+ try:
23929
+ os.kill(pid, 0)
23930
+ except ProcessLookupError:
23931
+ return "sigterm-took"
23932
+ except OSError:
23933
+ return "sigterm-took"
23934
+ time.sleep(0.01)
23935
+
23936
+ # Still alive after grace → SIGKILL fallback.
23937
+ try:
23938
+ os.kill(pid, _signal.SIGKILL)
23939
+ except ProcessLookupError:
23940
+ # Exited just at the grace boundary — count as SIGTERM-took.
23941
+ return "sigterm-took"
23942
+ except PermissionError:
23943
+ return "permission-denied"
23944
+ except OSError:
23945
+ # Same residual-OSError category as the SIGTERM site above.
23946
+ return "permission-denied"
23947
+ return "sigkill-took"
23948
+
23949
+
23950
+ def _legacy_cleanup_tmp_sentinels() -> list[str]:
23951
+ """Unlink the bespoke poller's PID + count files. Best-effort; missing
23952
+ files are silent no-ops (FileNotFoundError) and so are unwritable
23953
+ parents (OSError). Returns the paths actually unlinked, as strings,
23954
+ in canonical (pid, count) order.
23955
+
23956
+ Per spec Section 1 step 3 and Section 2 step 6b — runs after the
23957
+ SIGTERM/SIGKILL helper so a successful daemon stop also clears the
23958
+ sentinels it left on /tmp.
23959
+ """
23960
+ unlinked: list[str] = []
23961
+ for p in (_LEGACY_POLLER_PID_FILE, _LEGACY_POLLER_COUNT_FILE):
23962
+ try:
23963
+ p.unlink()
23964
+ except FileNotFoundError:
23965
+ continue
23966
+ except OSError:
23967
+ continue
23968
+ unlinked.append(str(p))
23969
+ return unlinked
23970
+
23971
+
23972
+ def _setup_read_legacy_prompt_input(stream, reprompt: str | None = None) -> bool:
23973
+ """Read a y/N answer from `stream` per spec Section 2 prompt rules.
23974
+
23975
+ Empty input (just Enter) → True (the documented default).
23976
+ 'y'/'yes' (any case) → True.
23977
+ 'n'/'no' (any case) → False.
23978
+ EOF before any character → False (decline; explicitly NOT default-Y, so
23979
+ non-TTY callers can't auto-accept via inherited stdin closure).
23980
+ Anything else → re-prompt up to 3 times, then False with a stderr warning.
23981
+
23982
+ `reprompt`: optional text to emit to stderr before each attempt AFTER the
23983
+ first (the caller already printed the original prompt body before calling
23984
+ us). When None (test default), no reprompt is emitted — useful for unit
23985
+ tests that drive `stream` from io.StringIO.
23986
+ """
23987
+ yes_words = {"y", "yes"}
23988
+ no_words = {"n", "no"}
23989
+ for attempt in range(3):
23990
+ if attempt > 0 and reprompt is not None:
23991
+ eprint(reprompt)
23992
+ line = stream.readline()
23993
+ if line == "":
23994
+ return False # EOF → decline
23995
+ token = line.strip().lower() # whitespace-only counts as "just Enter" → default-Y
23996
+ if token == "":
23997
+ return True
23998
+ if token in yes_words:
23999
+ return True
24000
+ if token in no_words:
24001
+ return False
24002
+ eprint("setup: invalid responses 3 times; skipping migration")
24003
+ return False
24004
+
24005
+
24006
+ def _setup_legacy_decide_action(args, detected: bool, stdin_isatty: bool) -> tuple[str, str | None]:
24007
+ """Decide migration action without performing prompt I/O.
24008
+
24009
+ Returns (decision, reason) where decision is one of:
24010
+ - "migrate" — proceed with migration
24011
+ - "skip" — do not migrate; reason is one of "not_detected" /
24012
+ "no_migrate_flag" / "user_declined". This helper never returns
24013
+ "user_declined"; that reason is set by the caller after a
24014
+ "prompt" decision yields a No answer from
24015
+ _setup_read_legacy_prompt_input.
24016
+ - "prompt" — caller must read user input via the prompt helper.
24017
+
24018
+ Spec Section 2 prompt rules: detection short-circuits, explicit flags
24019
+ are decisive, --yes implies migrate, --json or non-TTY without a flag
24020
+ skips silently (the JSON envelope and unattended runs both need a
24021
+ no-blocking-input contract). When none of those hold, the caller is
24022
+ in interactive install with detected hooks → prompt.
24023
+ """
24024
+ if not detected:
24025
+ return ("skip", "not_detected")
24026
+ if getattr(args, "no_migrate_legacy_hooks", False):
24027
+ return ("skip", "no_migrate_flag")
24028
+ if getattr(args, "migrate_legacy_hooks", False):
24029
+ return ("migrate", None)
24030
+ if getattr(args, "yes", False):
24031
+ return ("migrate", None)
24032
+ if not stdin_isatty:
24033
+ return ("skip", "no_migrate_flag")
24034
+ if getattr(args, "json", False):
24035
+ return ("skip", "no_migrate_flag")
24036
+ return ("prompt", None)
24037
+
24038
+
21749
24039
  def _setup_oauth_token_present() -> bool:
21750
24040
  try:
21751
24041
  return bool(_resolve_oauth_token())
@@ -21863,6 +24153,7 @@ def _setup_status(args: argparse.Namespace) -> int:
21863
24153
  throttle_age = _hook_tick_throttle_age_seconds()
21864
24154
  activity = _setup_recent_log_stats()
21865
24155
  legacy = _setup_detect_legacy_snippet()
24156
+ bespoke = _setup_detect_legacy_bespoke_hooks(settings)
21866
24157
  data_bytes = _setup_data_dir_size_bytes()
21867
24158
 
21868
24159
  if getattr(args, "json", False):
@@ -21881,7 +24172,14 @@ def _setup_status(args: argparse.Namespace) -> int:
21881
24172
  ),
21882
24173
  },
21883
24174
  "activity_24h": activity,
21884
- "legacy": {"statusline_snippet": str(legacy[0]) if legacy else None},
24175
+ "legacy": {
24176
+ "statusline_snippet": str(legacy[0]) if legacy else None,
24177
+ "bespoke_hooks": {
24178
+ "detected": bespoke["detected"],
24179
+ "settings_entries": bespoke["settings_entries"],
24180
+ "files": bespoke["files"],
24181
+ },
24182
+ },
21885
24183
  "data": {"path": str(APP_DIR), "size_bytes": data_bytes},
21886
24184
  }
21887
24185
  print(json.dumps(envelope, indent=2))
@@ -21919,6 +24217,15 @@ def _setup_status(args: argparse.Namespace) -> int:
21919
24217
  out.append(" status-line snippet not detected ✓")
21920
24218
  else:
21921
24219
  out.append(f" status-line snippet detected at {legacy[0]}:{legacy[1][0]} ⚠")
24220
+ if not bespoke["detected"]:
24221
+ out.append(" bespoke hooks not detected ✓")
24222
+ else:
24223
+ n_entries = len(bespoke["settings_entries"])
24224
+ n_files = len(bespoke["files"])
24225
+ out.append(
24226
+ f" bespoke hooks detected ({n_entries} entries, {n_files} files) ⚠"
24227
+ )
24228
+ out.append(" run `cctally setup --migrate-legacy-hooks` to migrate")
21922
24229
  out.append("Data")
21923
24230
  out.append(f" {APP_DIR}/ {_setup_format_bytes(data_bytes)}")
21924
24231
  _setup_emit_text(out)
@@ -22054,6 +24361,16 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
22054
24361
  def _setup_dry_run(args: argparse.Namespace) -> int:
22055
24362
  repo_root = _setup_resolve_repo_root()
22056
24363
  dst_dir = _setup_local_bin_dir()
24364
+ try:
24365
+ settings = _load_claude_settings()
24366
+ except SetupError as exc:
24367
+ # Malformed settings.json — preview still proceeds; legacy detection
24368
+ # against an empty dict simply yields detected=False for entries (files
24369
+ # detection is independent of settings). Mirror _setup_status's pattern
24370
+ # so the user sees the same condition that would fail _setup_install.
24371
+ eprint(f"setup: warning: {exc}")
24372
+ settings = {}
24373
+ detection = _setup_detect_legacy_bespoke_hooks(settings)
22057
24374
  sym_results = []
22058
24375
  for name in SETUP_SYMLINK_NAMES:
22059
24376
  dst = dst_dir / name
@@ -22086,12 +24403,63 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
22086
24403
  f" hooks.{ev}[*] += {{ matcher: {matcher}, "
22087
24404
  f"command: \"{quoted} hook-tick\" }}"
22088
24405
  )
24406
+ # Spec §2 mode×flag matrix — three distinct dry-run rendering paths
24407
+ # when legacy is detected:
24408
+ # --dry-run --no-migrate-legacy-hooks → migration block omitted entirely
24409
+ # --dry-run --migrate-legacy-hooks (or --yes) → full migration plan
24410
+ # --dry-run (no migrate flag) → full plan prefixed with the
24411
+ # "would prompt; pass --migrate-legacy-hooks…" note
24412
+ # `--yes` is treated as equivalent to `--migrate-legacy-hooks` to
24413
+ # match `_setup_decide_legacy_migration` (bin/cctally:22094-22101).
24414
+ no_migrate_flag = bool(getattr(args, "no_migrate_legacy_hooks", False))
24415
+ migrate_flag = bool(getattr(args, "migrate_legacy_hooks", False))
24416
+ yes_flag = bool(getattr(args, "yes", False))
24417
+ show_full_migration_plan = migrate_flag or yes_flag
24418
+ show_migration_block = detection["detected"] and not no_migrate_flag
24419
+ if show_migration_block:
24420
+ if not show_full_migration_plan:
24421
+ # No-flag dry-run: prefix the block with the would-prompt note.
24422
+ out.append(
24423
+ "Would prompt for migration; pass --migrate-legacy-hooks to "
24424
+ "preview the migration plan."
24425
+ )
24426
+ out.append("Would migrate legacy bespoke hooks:")
24427
+ if detection["settings_entries"]:
24428
+ out.append(
24429
+ f" Would remove {len(detection['settings_entries'])} "
24430
+ f"entries from settings.json:"
24431
+ )
24432
+ for e in detection["settings_entries"]:
24433
+ out.append(f" hooks.{e['event']:13s} ← {e['command']}")
24434
+ files_present = [f.split('/')[-1] for f in detection["files"]]
24435
+ if files_present:
24436
+ out.append(
24437
+ f" Would move {len(files_present)} files to "
24438
+ f"~/.claude/cctally-legacy-hook-backup-<UTC ts>/:"
24439
+ )
24440
+ out.append(f" {', '.join(files_present)}")
24441
+ out.append(" Would attempt cleanup of /tmp/claude-usage-poller.{pid,count}")
22089
24442
  out.append("Would not modify ~/.claude/statusline-command.sh")
22090
24443
  out.append("Would not delete any data")
22091
24444
  out.append("")
22092
24445
  out.append("Re-run without --dry-run to apply.")
22093
24446
 
22094
24447
  if getattr(args, "json", False):
24448
+ # Decision label mirrors `_setup_decide_legacy_migration`'s output:
24449
+ # `migrate` (full plan / explicit opt-in or --yes), `skip`
24450
+ # (--no-migrate-legacy-hooks), or `prompt` (no flag — install would
24451
+ # prompt the user). When no legacy is detected the label is
24452
+ # `not_detected` so consumers can distinguish "no-op" from
24453
+ # "explicit skip."
24454
+ if not detection["detected"]:
24455
+ decision = "not_detected"
24456
+ elif no_migrate_flag:
24457
+ decision = "skip"
24458
+ elif show_full_migration_plan:
24459
+ decision = "migrate"
24460
+ else:
24461
+ decision = "prompt"
24462
+ legacy_path = _setup_detect_legacy_snippet()
22095
24463
  envelope = {
22096
24464
  "schema_version": 1,
22097
24465
  "mode": "dry-run",
@@ -22113,6 +24481,39 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
22113
24481
  ],
22114
24482
  "settings_path": str(CLAUDE_SETTINGS_PATH),
22115
24483
  },
24484
+ # Sibling parity with `_setup_status` and `_setup_install`
24485
+ # JSON envelopes (`legacy.bespoke_hooks` shape). Lets the same
24486
+ # consumer query bespoke-hook state from any of the three
24487
+ # commands uniformly.
24488
+ "legacy": {
24489
+ "statusline_snippet": str(legacy_path[0]) if legacy_path else None,
24490
+ "bespoke_hooks": {
24491
+ "detected": detection["detected"],
24492
+ "settings_entries": detection["settings_entries"],
24493
+ "files": detection["files"],
24494
+ },
24495
+ },
24496
+ # Flag-aware preview block. `decision` records what the
24497
+ # install path would do; `would_remove_entries` /
24498
+ # `would_move_files` are the rendered plan (empty when
24499
+ # decision == "skip" or "not_detected").
24500
+ "migration_preview": {
24501
+ "detected": detection["detected"],
24502
+ "decision": decision,
24503
+ "would_remove_entries": (
24504
+ []
24505
+ if decision in ("skip", "not_detected")
24506
+ else [
24507
+ {"event": e["event"], "command": e["command"]}
24508
+ for e in detection["settings_entries"]
24509
+ ]
24510
+ ),
24511
+ "would_move_files": (
24512
+ []
24513
+ if decision in ("skip", "not_detected")
24514
+ else list(detection["files"])
24515
+ ),
24516
+ },
22116
24517
  "exit_code": 0,
22117
24518
  }
22118
24519
  print(json.dumps(envelope, indent=2))
@@ -22127,6 +24528,37 @@ def _setup_emit_text(lines: list[str]) -> None:
22127
24528
  print(ln)
22128
24529
 
22129
24530
 
24531
+ def _setup_render_legacy_prompt(detection: dict) -> str:
24532
+ """Return the multi-line prompt body per spec Section 2.
24533
+
24534
+ Renders the ⚠ header, one row per detected (event → file) settings
24535
+ entry, an optional daemon-source line for usage-poller.py, the
24536
+ explanation of the silent failure mode, and the [Y/n] question.
24537
+ Caller is expected to print the body once and then dispatch to
24538
+ `_setup_read_legacy_prompt_input` for the actual answer.
24539
+ """
24540
+ lines = ["⚠ Detected legacy bespoke hooks (predate `cctally setup`):"]
24541
+ by_event = {e["event"]: e["command"] for e in detection["settings_entries"]}
24542
+ for ev in ("Stop", "SubagentStart", "SubagentStop"):
24543
+ cmd = by_event.get(ev, "")
24544
+ if cmd:
24545
+ file_part = cmd.replace("python3 ", "")
24546
+ lines.append(f" {file_part:38s} → hooks.{ev}")
24547
+ if any("usage-poller.py" in f for f in detection["files"]):
24548
+ lines.append(" ~/.claude/hooks/usage-poller.py (daemon spawned by usage-poller-start.py)")
24549
+ lines += [
24550
+ "",
24551
+ " Their delegate binary isn't on PATH on this system — every fire has",
24552
+ " been silently failing.",
24553
+ "",
24554
+ " Migrate now? Will unwire the settings.json entries and move the .py files",
24555
+ " to ~/.claude/cctally-legacy-hook-backup-<UTC ts>/. Reversible.",
24556
+ "",
24557
+ " Migrate? [Y/n]",
24558
+ ]
24559
+ return "\n".join(lines)
24560
+
24561
+
22130
24562
  def _setup_install(args: argparse.Namespace) -> int:
22131
24563
  """Install path. Returns exit code per Section 2 of spec."""
22132
24564
  out: list[str] = []
@@ -22157,6 +24589,60 @@ def _setup_install(args: argparse.Namespace) -> int:
22157
24589
  except SetupError as exc:
22158
24590
  eprint(f"setup: {exc}")
22159
24591
  return 1
24592
+
24593
+ # ── Legacy bespoke hook detection + migration decision (spec §1, §2) ──
24594
+ # Detection is read-only on the in-memory settings dict; decision is
24595
+ # pure (no I/O); the prompt fires only when the decision helper
24596
+ # returns "prompt" (TTY + no flag + not --json). All three must run
24597
+ # BEFORE `_settings_merge_install` so the unwire+add land in the same
24598
+ # atomic write at sequence position 6.
24599
+ detection = _setup_detect_legacy_bespoke_hooks(settings)
24600
+ decision, reason = _setup_legacy_decide_action(
24601
+ args,
24602
+ detected=detection["detected"],
24603
+ stdin_isatty=sys.stdin.isatty(),
24604
+ )
24605
+ if decision == "prompt":
24606
+ print(_setup_render_legacy_prompt(detection))
24607
+ accepted = _setup_read_legacy_prompt_input(
24608
+ sys.stdin,
24609
+ reprompt="Please answer y or n. Migrate? [Y/n]",
24610
+ )
24611
+ decision = "migrate" if accepted else "skip"
24612
+ if not accepted:
24613
+ reason = "user_declined"
24614
+
24615
+ migration_summary: dict = {
24616
+ "performed": False,
24617
+ "reason": reason or "not_detected",
24618
+ }
24619
+
24620
+ backup_dir: pathlib.Path | None = None
24621
+ if decision == "migrate":
24622
+ # Resolve the backup dir BEFORE mutating settings.json so a
24623
+ # mkdir failure (parent unwriteable, name collision with a
24624
+ # regular file, ENOSPC, …) doesn't leave the on-disk settings
24625
+ # in a half-applied state — legacy entries gone but .py files
24626
+ # never moved. Pre-resolving also pins the timestamp shared
24627
+ # between the dir name and JSON envelope.
24628
+ try:
24629
+ backup_dir = _legacy_resolve_backup_dir()
24630
+ except OSError as exc:
24631
+ eprint(f"setup: cannot create migration backup dir: {exc}")
24632
+ return 1
24633
+ # Unwire BEFORE the merge so the same atomic write removes legacy
24634
+ # entries and adds cctally entries (spec §2 step 6).
24635
+ settings, n_unwired = _settings_merge_unwire_legacy(settings)
24636
+ migration_summary = {
24637
+ "performed": True,
24638
+ "settings_entries_removed": n_unwired,
24639
+ "files_moved": 0,
24640
+ "backup_dir": None,
24641
+ "active_poller_pid_signaled": None,
24642
+ "active_poller_kill_outcome": None,
24643
+ "tmp_files_unlinked": [],
24644
+ }
24645
+
22160
24646
  try:
22161
24647
  _settings_merge_install(settings, abs_path)
22162
24648
  except SetupError as exc:
@@ -22196,8 +24682,76 @@ def _setup_install(args: argparse.Namespace) -> int:
22196
24682
  except OSError as exc:
22197
24683
  eprint(f"setup: failed to write {CLAUDE_SETTINGS_PATH}: {exc}")
22198
24684
  return 2
24685
+
24686
+ # ── Post-write migration apply (spec §2 steps 6a, 6b) ──
24687
+ # Settings.json is now durable. File moves, poller stop, and tmp
24688
+ # cleanup are best-effort and may emit a partial-move warning, but
24689
+ # do NOT roll back the on-disk settings.json. Per spec §2 exit-code
24690
+ # table, partial-move failures are uniformly exit-0-with-warning.
24691
+ if decision == "migrate":
24692
+ # `backup_dir` was resolved early (pre-write) so the mkdir
24693
+ # failure path can fail fast with no settings.json mutation.
24694
+ assert backup_dir is not None
24695
+ # Snapshot what we expected to move BEFORE the move so we can
24696
+ # detect partial failure cleanly (post-loop, src files are gone).
24697
+ expected_to_move = [
24698
+ n for n in _LEGACY_BESPOKE_FILENAMES
24699
+ if (_LEGACY_BESPOKE_HOOKS_DIR / n).exists()
24700
+ ]
24701
+ moved = _legacy_move_files_to_backup(backup_dir)
24702
+ migration_summary["files_moved"] = len(moved)
24703
+ migration_summary["backup_dir"] = str(backup_dir)
24704
+ if len(moved) < len(expected_to_move):
24705
+ orphans = sorted(set(expected_to_move) - {p.name for p in moved})
24706
+ out.append(
24707
+ f"⚠ Partial file move: {len(moved)} of {len(expected_to_move)} expected "
24708
+ f"files moved. Orphans: {', '.join(orphans)}"
24709
+ )
24710
+ warnings += 1
24711
+
24712
+ # Active-poller stop + tmp-sentinel cleanup (best-effort, silent
24713
+ # on failure per spec §2 step 6b). Capture the pre-stop PID for
24714
+ # the JSON envelope since the helper itself returns only the
24715
+ # outcome string.
24716
+ pid_signaled: int | None = None
24717
+ if _LEGACY_POLLER_PID_FILE.exists():
24718
+ try:
24719
+ pid_signaled = int(
24720
+ _LEGACY_POLLER_PID_FILE.read_text(encoding="utf-8", errors="replace").strip()
24721
+ )
24722
+ except (OSError, ValueError):
24723
+ pass
24724
+ kill_outcome = _legacy_stop_active_poller()
24725
+ # Per spec §3 (`active_poller_pid_signaled` semantics): record the
24726
+ # PID only when we actually attempted to deliver a signal. Stale-PID
24727
+ # and no-pid-file outcomes are read-only paths, so the JSON envelope
24728
+ # should reflect "no signal sent" with a null PID.
24729
+ if kill_outcome not in {"sigterm-took", "sigkill-took", "permission-denied"}:
24730
+ pid_signaled = None
24731
+ migration_summary["active_poller_pid_signaled"] = pid_signaled
24732
+ migration_summary["active_poller_kill_outcome"] = kill_outcome
24733
+ migration_summary["tmp_files_unlinked"] = _legacy_cleanup_tmp_sentinels()
24734
+
24735
+ out.append(
24736
+ f"✓ Migrated {migration_summary['settings_entries_removed']} legacy hook entries "
24737
+ f"→ moved {len(moved)} files to {backup_dir}/"
24738
+ )
24739
+
24740
+ # The "✓ Wrote …" line follows any migrate-summary line so the
24741
+ # narrative reads "we did the migration, then wrote the new entries"
24742
+ # — matches the spec's success-path sample (Section 2).
22199
24743
  out.append(f"✓ Wrote {len(SETUP_HOOK_EVENTS)} hook entries to {CLAUDE_SETTINGS_PATH}")
22200
24744
 
24745
+ if decision == "skip" and reason in {"user_declined", "no_migrate_flag"}:
24746
+ files_str = "{record-usage-stop,usage-poller{,-start,-stop}}.py"
24747
+ out.append(
24748
+ f"⚠ Legacy bespoke hooks detected (predate `cctally setup`; failing "
24749
+ f"silently on this system). Skipped at your request. Re-run "
24750
+ f"`cctally setup --migrate-legacy-hooks` later, or remove them yourself. "
24751
+ f"The four `.py` files are at ~/.claude/hooks/{files_str}."
24752
+ )
24753
+ warnings += 1
24754
+
22201
24755
  oauth = _setup_oauth_token_present()
22202
24756
  if oauth:
22203
24757
  out.append("✓ Detected OAuth token")
@@ -22254,13 +24808,22 @@ def _setup_install(args: argparse.Namespace) -> int:
22254
24808
 
22255
24809
  out.append("")
22256
24810
  if warnings:
22257
- out.append(f"cctally is ready (with {warnings} warning(s) above). Try:")
24811
+ out.append(f"cctally is ready (with {warnings} warning(s) above).")
22258
24812
  else:
22259
- out.append("cctally is ready. Try:")
22260
- out.append(" cctally daily # last 30 days")
22261
- out.append(" cctally dashboard # live web dashboard")
22262
- out.append(" cctally tui # terminal dashboard")
22263
- out.append(" cctally setup --status # verify install state")
24813
+ out.append("cctally is ready.")
24814
+ out.append("")
24815
+ # Settings.json was modified CC caches it at session start. The
24816
+ # warning fires unconditionally because `_setup_install` always
24817
+ # rewrites settings.json (legacy migration, fresh install, repair).
24818
+ out.append("⚠ Restart Claude Code for the new hooks to take effect in any currently")
24819
+ out.append(" open sessions. New sessions launched after this point pick them up")
24820
+ out.append(" automatically. (settings.json is cached at session start.)")
24821
+ out.append("")
24822
+ out.append(" Try:")
24823
+ out.append(" cctally daily # last 30 days")
24824
+ out.append(" cctally dashboard # live web dashboard")
24825
+ out.append(" cctally tui # terminal dashboard")
24826
+ out.append(" cctally setup --status # verify install state")
22264
24827
 
22265
24828
  if getattr(args, "json", False):
22266
24829
  envelope = {
@@ -22284,7 +24847,13 @@ def _setup_install(args: argparse.Namespace) -> int:
22284
24847
  "path_includes_local_bin": _setup_path_includes_local_bin(),
22285
24848
  "legacy": {
22286
24849
  "statusline_snippet_path": str(legacy[0]) if legacy else None,
24850
+ "bespoke_hooks": {
24851
+ "detected": detection["detected"],
24852
+ "settings_entries": detection["settings_entries"],
24853
+ "files": detection["files"],
24854
+ },
22287
24855
  },
24856
+ "migration": migration_summary,
22288
24857
  "bootstrap": {
22289
24858
  "session_cache_rows": bootstrap_rows,
22290
24859
  "oauth_status": bootstrap_oauth_status,
@@ -22299,7 +24868,13 @@ def _setup_install(args: argparse.Namespace) -> int:
22299
24868
  return 0
22300
24869
 
22301
24870
 
22302
- ALLOWED_CONFIG_KEYS = ("display.tz", "alerts.enabled", "dashboard.bind")
24871
+ ALLOWED_CONFIG_KEYS = (
24872
+ "display.tz",
24873
+ "alerts.enabled",
24874
+ "dashboard.bind",
24875
+ "update.check.enabled",
24876
+ "update.check.ttl_hours",
24877
+ )
22303
24878
 
22304
24879
 
22305
24880
  def cmd_config(args: argparse.Namespace) -> int:
@@ -22353,6 +24928,26 @@ def _config_known_value(config: dict, key: str) -> "object":
22353
24928
  # Hand-edited junk: surface the default rather than the bad value;
22354
24929
  # `cmd_dashboard` warns at server-start when it hits the same path.
22355
24930
  return "loopback"
24931
+ if key in ("update.check.enabled", "update.check.ttl_hours"):
24932
+ # Defaults mirror `_is_update_check_due` (True / 24 hours).
24933
+ # Hand-edited junk surfaces as the default — matches dashboard.bind.
24934
+ update_block = (
24935
+ config.get("update") if isinstance(config, dict) else None
24936
+ )
24937
+ if not isinstance(update_block, dict):
24938
+ update_block = {}
24939
+ check_block = update_block.get("check")
24940
+ if not isinstance(check_block, dict):
24941
+ check_block = {}
24942
+ if key == "update.check.enabled":
24943
+ stored = check_block.get("enabled", True)
24944
+ return bool(stored) if isinstance(stored, bool) else True
24945
+ # update.check.ttl_hours
24946
+ stored = check_block.get("ttl_hours", UPDATE_DEFAULT_TTL_HOURS)
24947
+ try:
24948
+ return _validate_update_check_ttl_hours_value(stored)
24949
+ except ValueError:
24950
+ return UPDATE_DEFAULT_TTL_HOURS
22356
24951
  return None
22357
24952
 
22358
24953
 
@@ -22371,10 +24966,18 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
22371
24966
  pairs.append((key, v if v is not None else ""))
22372
24967
 
22373
24968
  if getattr(args, "emit_json", False):
22374
- out: "dict[str, dict]" = {}
24969
+ # Walk every dot-delimited segment so keys deeper than two
24970
+ # segments (e.g. `update.check.enabled`) nest correctly. The
24971
+ # earlier `partition` form collapsed three-segment keys into
24972
+ # a flat tail (`{"update": {"check.enabled": ...}}`) and
24973
+ # diverged from `config set --json` / on-disk shape.
24974
+ out: "dict[str, object]" = {}
22375
24975
  for k, v in pairs:
22376
- head, _, tail = k.partition(".")
22377
- out.setdefault(head, {})[tail] = v
24976
+ segments = k.split(".")
24977
+ node: dict = out
24978
+ for seg in segments[:-1]:
24979
+ node = node.setdefault(seg, {})
24980
+ node[segments[-1]] = v
22378
24981
  print(json.dumps(out, indent=2))
22379
24982
  else:
22380
24983
  for k, v in pairs:
@@ -22479,6 +25082,53 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
22479
25082
  else:
22480
25083
  print(f"dashboard.bind={canonical}")
22481
25084
  return 0
25085
+ if key in ("update.check.enabled", "update.check.ttl_hours"):
25086
+ # Validate first; rejection short-circuits before lock acquisition.
25087
+ if key == "update.check.enabled":
25088
+ try:
25089
+ normalized: object = _normalize_update_check_enabled_value(raw)
25090
+ except ValueError as exc:
25091
+ print(f"cctally: {exc}", file=sys.stderr)
25092
+ return 2
25093
+ inner_key = "enabled"
25094
+ else:
25095
+ try:
25096
+ normalized = _validate_update_check_ttl_hours_value(raw)
25097
+ except ValueError as exc:
25098
+ print(f"cctally: {exc}", file=sys.stderr)
25099
+ return 2
25100
+ inner_key = "ttl_hours"
25101
+ with config_writer_lock():
25102
+ config = _load_config_unlocked()
25103
+ existing_update = config.get("update")
25104
+ if existing_update is not None and not isinstance(existing_update, dict):
25105
+ print(
25106
+ "cctally: update config error: update must be an object",
25107
+ file=sys.stderr,
25108
+ )
25109
+ return 2
25110
+ update_block = dict(existing_update or {})
25111
+ existing_check = update_block.get("check")
25112
+ if existing_check is not None and not isinstance(existing_check, dict):
25113
+ print(
25114
+ "cctally: update config error: update.check must be an object",
25115
+ file=sys.stderr,
25116
+ )
25117
+ return 2
25118
+ check_block = dict(existing_check or {})
25119
+ check_block[inner_key] = normalized
25120
+ update_block["check"] = check_block
25121
+ config["update"] = update_block
25122
+ save_config(config)
25123
+ if getattr(args, "emit_json", False):
25124
+ print(json.dumps({"update": {"check": {inner_key: normalized}}}, indent=2))
25125
+ else:
25126
+ if isinstance(normalized, bool):
25127
+ rendered = "true" if normalized else "false"
25128
+ else:
25129
+ rendered = str(normalized)
25130
+ print(f"{key}={rendered}")
25131
+ return 0
22482
25132
  return 2 # unreachable given the gate above
22483
25133
 
22484
25134
 
@@ -22530,6 +25180,26 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
22530
25180
  save_config(config)
22531
25181
  # idempotent: silent on missing key
22532
25182
  return 0
25183
+ if key in ("update.check.enabled", "update.check.ttl_hours"):
25184
+ # Mirror the dashboard.bind branch: drop the leaf, then prune
25185
+ # empty `check` and empty `update` so config.json stays tidy.
25186
+ inner_key = (
25187
+ "enabled" if key == "update.check.enabled" else "ttl_hours"
25188
+ )
25189
+ with config_writer_lock():
25190
+ config = _load_config_unlocked()
25191
+ update_block = config.get("update")
25192
+ if isinstance(update_block, dict):
25193
+ check_block = update_block.get("check")
25194
+ if isinstance(check_block, dict) and inner_key in check_block:
25195
+ del check_block[inner_key]
25196
+ if not check_block:
25197
+ del update_block["check"]
25198
+ if not update_block:
25199
+ config.pop("update", None)
25200
+ save_config(config)
25201
+ # idempotent: silent on missing key
25202
+ return 0
22533
25203
  return 2 # unreachable given the gate above
22534
25204
 
22535
25205
 
@@ -22586,6 +25256,19 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
22586
25256
 
22587
25257
 
22588
25258
  def cmd_setup(args: argparse.Namespace) -> int:
25259
+ # Migration flags are install-mode-only. Reject combinations with
25260
+ # --status or --uninstall (per spec Section 2 mode×flag matrix). The
25261
+ # mutex group on the parser already prevents both flags being set
25262
+ # together; here we guard the mode-axis pairing that argparse can't
25263
+ # express in a single mutex group.
25264
+ mig_flag = (
25265
+ "--migrate-legacy-hooks" if getattr(args, "migrate_legacy_hooks", False)
25266
+ else "--no-migrate-legacy-hooks" if getattr(args, "no_migrate_legacy_hooks", False)
25267
+ else None
25268
+ )
25269
+ if mig_flag and (getattr(args, "status", False) or getattr(args, "uninstall", False)):
25270
+ eprint(f"setup: {mig_flag} is install-mode only")
25271
+ return 2
22589
25272
  if getattr(args, "uninstall", False):
22590
25273
  return _setup_uninstall(args)
22591
25274
  if getattr(args, "status", False):
@@ -24472,6 +27155,17 @@ def build_parser() -> argparse.ArgumentParser:
24472
27155
  help="Skip confirmations")
24473
27156
  sp.add_argument("--json", action="store_true",
24474
27157
  help="Emit machine-readable output")
27158
+ # Legacy bespoke-hook migration flags (install-mode only — see cmd_setup
27159
+ # post-parse validation). Spec Section 2 mode×flag matrix.
27160
+ mig_group = sp.add_mutually_exclusive_group()
27161
+ mig_group.add_argument(
27162
+ "--migrate-legacy-hooks", action="store_true", dest="migrate_legacy_hooks",
27163
+ help="Auto-accept the legacy-bespoke-hook migration prompt (install only).",
27164
+ )
27165
+ mig_group.add_argument(
27166
+ "--no-migrate-legacy-hooks", action="store_true", dest="no_migrate_legacy_hooks",
27167
+ help="Auto-skip the legacy-bespoke-hook migration prompt (install only).",
27168
+ )
24475
27169
  sp.set_defaults(func=cmd_setup)
24476
27170
 
24477
27171
  # ---- db (migration framework — spec §4) ----
@@ -24620,6 +27314,11 @@ def build_parser() -> argparse.ArgumentParser:
24620
27314
  action="store_true",
24621
27315
  help="Skip Phase 6 (brew formula bump). Idempotent: re-running --resume without it picks the channel back up.",
24622
27316
  )
27317
+ p_release.add_argument(
27318
+ "--allow-formula-downgrade",
27319
+ action="store_true",
27320
+ help="Override Phase 6's monotonic-version gate (issue #30). Use only for intentional yank/revert cases — Phase 6 normally refuses to write a formula whose URL pins a lower SemVer than the on-disk formula.",
27321
+ )
24623
27322
  p_release.set_defaults(func=cmd_release)
24624
27323
 
24625
27324
  # ---- hook-tick (internal — hidden from --help, see onboarding spec §3) ----
@@ -24650,6 +27349,81 @@ def build_parser() -> argparse.ArgumentParser:
24650
27349
  help=argparse.SUPPRESS) # JSON string fed to mock fetch (tests only)
24651
27350
  ht.set_defaults(func=cmd_hook_tick)
24652
27351
 
27352
+ # ---- update (user-facing self-update subcommand; spec §4) ----
27353
+ sub_update = sub.add_parser(
27354
+ "update",
27355
+ help="Update cctally to the latest version",
27356
+ formatter_class=CLIHelpFormatter,
27357
+ description=textwrap.dedent(
27358
+ """\
27359
+ Update cctally to the latest version (npm/brew installs only).
27360
+
27361
+ Modes:
27362
+ cctally update install the latest version
27363
+ cctally update --check show update info without installing
27364
+ cctally update --skip [VER] don't remind about VER (default: latest)
27365
+ cctally update --remind-later [DAYS] defer the banner (default: 7)
27366
+ """
27367
+ ),
27368
+ )
27369
+ update_modes = sub_update.add_mutually_exclusive_group()
27370
+ update_modes.add_argument(
27371
+ "--check", action="store_true",
27372
+ help="Show update info without installing",
27373
+ )
27374
+ update_modes.add_argument(
27375
+ "--skip", nargs="?", const=SKIP_USE_STATE_LATEST, metavar="VERSION",
27376
+ default=None,
27377
+ help="Skip a specific version (default: latest in cache)",
27378
+ )
27379
+ update_modes.add_argument(
27380
+ "--remind-later", nargs="?", type=int, const=7, metavar="DAYS",
27381
+ default=None,
27382
+ help="Defer reminders by N days (default: 7)",
27383
+ )
27384
+ # `--version` here is local to the update subparser. The subparser's
27385
+ # value is bound to `args.install_version` (NOT `args.version`) to
27386
+ # avoid a namespace collision with the top-level `--version`
27387
+ # (store_true) flag handled in `main()` before subcommand dispatch:
27388
+ # if both used `dest="version"`, `cctally update --version 1.2.3`
27389
+ # would set `args.version="1.2.3"`, which `main()`'s truthy check
27390
+ # would treat as "global --version requested" and short-circuit to
27391
+ # print the version banner before `cmd_update` ever ran.
27392
+ sub_update.add_argument(
27393
+ "--version", metavar="X.Y.Z", default=None, dest="install_version",
27394
+ help="Install a specific version (npm only; brew has no versioned formulae)",
27395
+ )
27396
+ sub_update.add_argument(
27397
+ "--dry-run", action="store_true",
27398
+ help="Show what would happen, don't install",
27399
+ )
27400
+ sub_update.add_argument(
27401
+ "--force", action="store_true",
27402
+ help="Bypass TTL on --check (force a fresh remote fetch)",
27403
+ )
27404
+ sub_update.add_argument(
27405
+ "--json", action="store_true",
27406
+ help="Emit JSON output (mostly with --check)",
27407
+ )
27408
+ sub_update.set_defaults(func=cmd_update)
27409
+
27410
+ # ---- _update-check (internal — hidden, detached-refresh worker for `cctally update`) ----
27411
+ uc = sub.add_parser(
27412
+ "_update-check",
27413
+ help=argparse.SUPPRESS,
27414
+ formatter_class=CLIHelpFormatter,
27415
+ description=textwrap.dedent(
27416
+ """\
27417
+ Internal subcommand: detached version-check worker spawned
27418
+ by `cctally update` (spec §3.6). Touches the throttle
27419
+ marker, fetches the latest version from npm or homebrew
27420
+ depending on install method, and writes update-state.json.
27421
+ Always returns 0; failures are logged to update.log.
27422
+ """
27423
+ ),
27424
+ )
27425
+ uc.set_defaults(func=cmd_update_check_internal)
27426
+
24653
27427
  # Python 3.14 leaks `==SUPPRESS==` for hidden subparsers in --help; strip
24654
27428
  # the pseudo-action so the row disappears entirely. (The choice still
24655
27429
  # appears in the `{...}` choices header — there's no clean way to hide
@@ -29128,6 +31902,35 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
29128
31902
  "five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
29129
31903
  }
29130
31904
 
31905
+ # Mirror update-state.json + update-suppress.json into the envelope
31906
+ # so the dashboard's amber "Update available" badge repaints from
31907
+ # the SSE channel rather than requiring a /api/update/status fetch
31908
+ # per check. Without this mirror, a long-open dashboard tab never
31909
+ # learns that the dashboard's `_DashboardUpdateCheckThread` wrote
31910
+ # a fresher latest_version (24h-TTL by default) — the badge stayed
31911
+ # hidden until manual reload. Same precedent as `alerts_settings`:
31912
+ # cheap atomic-rename reads on the snapshot hot path. Failures
31913
+ # produce a `null` block so a missing/corrupt state file doesn't
31914
+ # 500 the entire envelope; the client falls back to the defensive
31915
+ # null-state shape `coerceUpdateState` already understands.
31916
+ try:
31917
+ _update_state_envelope = _load_update_state()
31918
+ except UpdateError:
31919
+ # _load_update_state() raises on truly malformed JSON. Surface
31920
+ # an _error sentinel so the client renders "no update info" the
31921
+ # same way it does for unreachable /api/update/status.
31922
+ _update_state_envelope = {"_error": "update-state.json invalid"}
31923
+ except Exception:
31924
+ _update_state_envelope = {"_error": "update-state.json read failed"}
31925
+ try:
31926
+ _update_suppress_envelope = _load_update_suppress()
31927
+ except Exception:
31928
+ _update_suppress_envelope = {"skipped_versions": [], "remind_after": None}
31929
+ update_envelope = {
31930
+ "state": _update_state_envelope,
31931
+ "suppress": _update_suppress_envelope,
31932
+ }
31933
+
29131
31934
  return {
29132
31935
  "envelope_version": 2,
29133
31936
  "generated_at": _iso_z(snap.generated_at),
@@ -29257,6 +32060,13 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
29257
32060
  # threshold-actions T5: see prelude above for rationale.
29258
32061
  "alerts": alerts_array,
29259
32062
  "alerts_settings": alerts_settings,
32063
+
32064
+ # update-subcommand SSE mirror (see comment above the
32065
+ # `_load_update_state()` block). Shape matches GET
32066
+ # /api/update/status's payload (`{state, suppress}`) so the
32067
+ # dashboard client's existing coerceUpdateState/Suppress logic
32068
+ # consumes both surfaces uniformly.
32069
+ "update": update_envelope,
29260
32070
  }
29261
32071
 
29262
32072
 
@@ -29401,6 +32211,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29401
32211
  self._handle_get_session_detail(path)
29402
32212
  elif path.startswith("/api/block/"):
29403
32213
  self._handle_get_block_detail(path)
32214
+ elif path == "/api/update/status":
32215
+ self._handle_get_update_status()
32216
+ elif path.startswith("/api/update/stream/"):
32217
+ self._handle_get_update_stream(path)
29404
32218
  else:
29405
32219
  self.send_error(404, "not found")
29406
32220
 
@@ -29412,6 +32226,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29412
32226
  self._handle_post_settings()
29413
32227
  elif path == "/api/alerts/test":
29414
32228
  self._handle_post_alerts_test()
32229
+ elif path == "/api/update":
32230
+ self._handle_post_update()
32231
+ elif path == "/api/update/dismiss":
32232
+ self._handle_post_update_dismiss()
29415
32233
  else:
29416
32234
  self.send_error(404, "not found")
29417
32235
 
@@ -29543,10 +32361,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29543
32361
  def _handle_post_settings(self) -> None:
29544
32362
  """Persist a settings update and trigger an immediate SSE broadcast.
29545
32363
 
29546
- Body shape (T7 extension): ``{"display"?: {"tz": "..."}, "alerts"?: {...}}``
29547
- both top-level keys are optional; either may be sent alone or
29548
- both together (combined save). Unknown top-level keys are
29549
- rejected with 400.
32364
+ Body shape: ``{"display"?: {"tz": "..."}, "alerts"?: {...},
32365
+ "update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}}}``
32366
+ every top-level key is optional; any subset may be sent
32367
+ together (combined save). Unknown top-level keys are rejected
32368
+ with 400.
29550
32369
 
29551
32370
  Per-block validation:
29552
32371
  * ``display.tz`` — "local", "utc", or a valid IANA zone (via
@@ -29555,11 +32374,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29555
32374
  JSON boolean (string "yes"/"true" rejected, per spec). Merged
29556
32375
  block is validated via ``_get_alerts_config(merged)``;
29557
32376
  ``_AlertsConfigError`` → 400.
32377
+ * ``update.check.enabled`` — JSON bool; 400 on type mismatch.
32378
+ * ``update.check.ttl_hours`` — JSON int (NOT string), in
32379
+ ``[1, 720]``; 400 on out-of-range or non-int. Bool is rejected
32380
+ (Python ``True`` is an int subclass, so a permissive check
32381
+ would silently accept ``true`` for a numeric field).
29558
32382
 
29559
- Atomic merged write: if BOTH blocks validate, the merged config
29560
- is persisted in a single ``save_config`` call inside the
29561
- ``config_writer_lock``. If validation fails for one block (or
29562
- both), nothing is persisted — no partial commits.
32383
+ Atomic merged write: if all touched blocks validate, the merged
32384
+ config is persisted in a single ``save_config`` call inside the
32385
+ ``config_writer_lock``. If any block fails validation, nothing
32386
+ is persisted — no partial commits.
29563
32387
 
29564
32388
  Security: Origin must match the request's Host header (Host-header
29565
32389
  parity; see ``_check_origin_csrf``). Missing Origin → 403.
@@ -29606,7 +32430,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29606
32430
  return
29607
32431
 
29608
32432
  # Reject unknown top-level keys (forward-compat hygiene).
29609
- allowed_top_keys = {"display", "alerts"}
32433
+ allowed_top_keys = {"display", "alerts", "update"}
29610
32434
  for k in payload.keys():
29611
32435
  if k not in allowed_top_keys:
29612
32436
  self._respond_json(
@@ -29615,10 +32439,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29615
32439
  return
29616
32440
 
29617
32441
  # Body must touch at least one known block.
29618
- if "display" not in payload and "alerts" not in payload:
32442
+ if (
32443
+ "display" not in payload
32444
+ and "alerts" not in payload
32445
+ and "update" not in payload
32446
+ ):
29619
32447
  self._respond_json(
29620
32448
  400,
29621
- {"error": "body must contain at least one of: display, alerts"},
32449
+ {"error": (
32450
+ "body must contain at least one of: "
32451
+ "display, alerts, update"
32452
+ )},
29622
32453
  )
29623
32454
  return
29624
32455
 
@@ -29673,6 +32504,66 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29673
32504
  )
29674
32505
  return
29675
32506
 
32507
+ # Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
32508
+ # is settable today; any other key under `update` or `update.check`
32509
+ # is rejected so adding e.g. `update.banner.*` later is forward
32510
+ # compatible. `enabled` must be a JSON bool; `ttl_hours` an int
32511
+ # (bools rejected — see _validate_update_check_ttl_hours_value).
32512
+ update_check_validated: "dict | None" = None
32513
+ if "update" in payload:
32514
+ update_in = payload["update"]
32515
+ if not isinstance(update_in, dict):
32516
+ self._respond_json(
32517
+ 400, {"error": "update must be an object"}
32518
+ )
32519
+ return
32520
+ for inner in update_in.keys():
32521
+ if inner != "check":
32522
+ self._respond_json(
32523
+ 400,
32524
+ {"error": f"unknown update settings key: {inner}",
32525
+ "field": f"update.{inner}"},
32526
+ )
32527
+ return
32528
+ check_in = update_in.get("check", {})
32529
+ if not isinstance(check_in, dict):
32530
+ self._respond_json(
32531
+ 400,
32532
+ {"error": "update.check must be an object",
32533
+ "field": "update.check"},
32534
+ )
32535
+ return
32536
+ for leaf in check_in.keys():
32537
+ if leaf not in ("enabled", "ttl_hours"):
32538
+ self._respond_json(
32539
+ 400,
32540
+ {"error": f"unknown update.check key: {leaf}",
32541
+ "field": f"update.check.{leaf}"},
32542
+ )
32543
+ return
32544
+ update_check_validated = {}
32545
+ if "enabled" in check_in:
32546
+ if not isinstance(check_in["enabled"], bool):
32547
+ self._respond_json(
32548
+ 400,
32549
+ {"error": "update.check.enabled must be a JSON boolean",
32550
+ "field": "update.check.enabled"},
32551
+ )
32552
+ return
32553
+ update_check_validated["enabled"] = check_in["enabled"]
32554
+ if "ttl_hours" in check_in:
32555
+ try:
32556
+ update_check_validated["ttl_hours"] = (
32557
+ _validate_update_check_ttl_hours_value(check_in["ttl_hours"])
32558
+ )
32559
+ except ValueError as exc:
32560
+ self._respond_json(
32561
+ 400,
32562
+ {"error": str(exc),
32563
+ "field": "update.check.ttl_hours"},
32564
+ )
32565
+ return
32566
+
29676
32567
  # Acquire config_writer_lock so a concurrent `cctally config set`
29677
32568
  # in another shell can't interleave its write between our load
29678
32569
  # and save (issue #17). Lock is process-cross via fcntl.flock,
@@ -29721,6 +32612,34 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29721
32612
  self._respond_json(400, {"error": str(exc)})
29722
32613
  return
29723
32614
 
32615
+ if update_check_validated is not None:
32616
+ # Same hand-edited-junk guard as alerts: a non-dict
32617
+ # `update` or `update.check` block in config.json should
32618
+ # surface as a recoverable 400, not a 500.
32619
+ existing_update = merged.get("update")
32620
+ if existing_update is not None and not isinstance(
32621
+ existing_update, dict
32622
+ ):
32623
+ self._respond_json(
32624
+ 400, {"error": "update must be an object",
32625
+ "field": "update"}
32626
+ )
32627
+ return
32628
+ merged_update = dict(existing_update or {})
32629
+ existing_check = merged_update.get("check")
32630
+ if existing_check is not None and not isinstance(
32631
+ existing_check, dict
32632
+ ):
32633
+ self._respond_json(
32634
+ 400, {"error": "update.check must be an object",
32635
+ "field": "update.check"}
32636
+ )
32637
+ return
32638
+ merged_check = dict(existing_check or {})
32639
+ merged_check.update(update_check_validated)
32640
+ merged_update["check"] = merged_check
32641
+ merged["update"] = merged_update
32642
+
29724
32643
  save_config(merged)
29725
32644
 
29726
32645
  # Build the response: subset of touched blocks.
@@ -29731,6 +32650,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
29731
32650
  )
29732
32651
  if "alerts" in payload:
29733
32652
  out["alerts"] = _get_alerts_config(merged)
32653
+ if update_check_validated is not None:
32654
+ # Echo the full merged check block (cooked defaults included)
32655
+ # so the SettingsOverlay can repaint without a follow-up GET.
32656
+ out["update"] = {
32657
+ "check": {
32658
+ "enabled": _config_known_value(
32659
+ merged, "update.check.enabled"
32660
+ ),
32661
+ "ttl_hours": _config_known_value(
32662
+ merged, "update.check.ttl_hours"
32663
+ ),
32664
+ }
32665
+ }
29734
32666
  out["saved_at"] = (
29735
32667
  dt.datetime.now(dt.timezone.utc)
29736
32668
  .strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -30094,6 +33026,194 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
30094
33026
  finally:
30095
33027
  self.hub.unsubscribe(q)
30096
33028
 
33029
+ # --- /api/update* (spec §5.6, §5.6.1, §5.7) ----------------------
33030
+ # The four endpoints share the module-level singleton
33031
+ # ``_UPDATE_WORKER`` which is created in ``cmd_dashboard``. POST
33032
+ # routes pass through ``_check_origin_csrf`` (host-header parity);
33033
+ # GET routes are read-only and skip CSRF intentionally so polling
33034
+ # fallbacks survive odd Origin shapes (e.g. native browser tools).
33035
+
33036
+ def _read_update_post_body(self) -> "dict | None":
33037
+ """Read + parse a small JSON body for /api/update* POSTs.
33038
+
33039
+ Empty body is allowed (returns ``{}``). Non-dict / malformed →
33040
+ respond 400 and return ``None`` so the caller short-circuits.
33041
+ Body is capped at 4 KB — same envelope as /api/settings.
33042
+ """
33043
+ try:
33044
+ length = int(self.headers.get("Content-Length", "0") or "0")
33045
+ except ValueError:
33046
+ length = 0
33047
+ if length < 0 or length > 4096:
33048
+ self._respond_json(400, {"error": "body too large (<=4 KB)"})
33049
+ return None
33050
+ if length == 0:
33051
+ return {}
33052
+ try:
33053
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
33054
+ except (UnicodeDecodeError, json.JSONDecodeError):
33055
+ self._respond_json(400, {"error": "malformed json"})
33056
+ return None
33057
+ if not isinstance(payload, dict):
33058
+ self._respond_json(400, {"error": "expected JSON object"})
33059
+ return None
33060
+ return payload
33061
+
33062
+ def _handle_post_update(self) -> None:
33063
+ """POST /api/update — kick off an in-process update.
33064
+
33065
+ Body: ``{"version"?: "X.Y.Z"}``. CSRF-gated. Returns
33066
+ 202 + ``{"run_id": ...}`` on accept; 409 + ``{"run_id_in_progress": ...}``
33067
+ when another run is already in progress.
33068
+ """
33069
+ if not self._check_origin_csrf():
33070
+ return
33071
+ payload = self._read_update_post_body()
33072
+ if payload is None:
33073
+ return
33074
+ worker = _UPDATE_WORKER
33075
+ if worker is None:
33076
+ self._respond_json(
33077
+ 500, {"error": "update worker not initialized"}
33078
+ )
33079
+ return
33080
+ version = payload.get("version") if isinstance(payload, dict) else None
33081
+ if version is not None and not isinstance(version, str):
33082
+ self._respond_json(
33083
+ 400, {"error": "version must be a string"}
33084
+ )
33085
+ return
33086
+ accepted, run_id = worker.start(version)
33087
+ if accepted:
33088
+ self._respond_json(202, {"run_id": run_id})
33089
+ else:
33090
+ self._respond_json(409, {"run_id_in_progress": run_id})
33091
+
33092
+ def _handle_post_update_dismiss(self) -> None:
33093
+ """POST /api/update/dismiss — record a skip / remind-later.
33094
+
33095
+ Body: ``{"action": "skip"|"remind", "version"?: "X.Y.Z", "days"?: int}``.
33096
+ CSRF-gated. Mutates ``update-suppress.json`` via the same
33097
+ ``_do_update_skip`` / ``_do_update_remind_later`` helpers the
33098
+ CLI uses, so the on-disk shape stays single-source-of-truth.
33099
+ Returns 204 on success, 400 on invalid action / shape.
33100
+ """
33101
+ if not self._check_origin_csrf():
33102
+ return
33103
+ payload = self._read_update_post_body()
33104
+ if payload is None:
33105
+ return
33106
+ action = payload.get("action")
33107
+ # Suppress stdout/stderr from _do_update_skip /
33108
+ # _do_update_remind_later so their CLI-style "Skipped …" /
33109
+ # "Will remind …" prints don't pollute the dashboard server's
33110
+ # log stream. The HTTP response is the user-facing surface.
33111
+ with contextlib.redirect_stdout(io.StringIO()), \
33112
+ contextlib.redirect_stderr(io.StringIO()):
33113
+ if action == "skip":
33114
+ ver = payload.get("version")
33115
+ if ver is None or ver == "":
33116
+ rc = _do_update_skip(SKIP_USE_STATE_LATEST)
33117
+ elif not isinstance(ver, str):
33118
+ self._respond_json(
33119
+ 400, {"error": "version must be a string"}
33120
+ )
33121
+ return
33122
+ else:
33123
+ rc = _do_update_skip(ver)
33124
+ elif action == "remind":
33125
+ days_raw = payload.get("days", 7)
33126
+ try:
33127
+ days = int(days_raw)
33128
+ except (TypeError, ValueError):
33129
+ self._respond_json(
33130
+ 400, {"error": "days must be an integer"}
33131
+ )
33132
+ return
33133
+ rc = _do_update_remind_later(days)
33134
+ else:
33135
+ self._respond_json(
33136
+ 400,
33137
+ {
33138
+ "error": (
33139
+ "action must be 'skip' or 'remind'"
33140
+ )
33141
+ },
33142
+ )
33143
+ return
33144
+ if rc != 0:
33145
+ # Helper printed an explanation to stderr (now swallowed).
33146
+ # Map to a generic 400; UI's polling status will show the
33147
+ # latest state. Future surface improvement: have the
33148
+ # helpers return a structured error so we can echo the
33149
+ # specific reason ("no version in cache to skip") here.
33150
+ self._respond_json(400, {"error": "dismiss failed"})
33151
+ return
33152
+ self.send_response(204)
33153
+ self.end_headers()
33154
+
33155
+ def _handle_get_update_status(self) -> None:
33156
+ """GET /api/update/status — return state + suppress + worker status.
33157
+
33158
+ Polling-fallback friendly so a browser that missed the SSE
33159
+ execvp event can detect "no run in progress" and re-render the
33160
+ idle modal state. ``state`` and ``suppress`` come from the
33161
+ canonical helpers so the on-disk shape is single-source-of-truth.
33162
+ """
33163
+ try:
33164
+ state = _load_update_state()
33165
+ except UpdateError as e:
33166
+ state = {"_error": str(e)[:200]}
33167
+ try:
33168
+ suppress = _load_update_suppress()
33169
+ except UpdateError as e:
33170
+ suppress = {"_error": str(e)[:200]}
33171
+ worker_status = (
33172
+ _UPDATE_WORKER.status() if _UPDATE_WORKER is not None
33173
+ else {"current_run_id": None}
33174
+ )
33175
+ body = {
33176
+ "state": state,
33177
+ "suppress": suppress,
33178
+ **worker_status,
33179
+ }
33180
+ self._respond_json(200, body)
33181
+
33182
+ def _handle_get_update_stream(self, path: str) -> None:
33183
+ """GET /api/update/stream/<run_id> — SSE event stream.
33184
+
33185
+ Yields events from ``UpdateWorker.stream(run_id)``. Closes on
33186
+ the worker's terminal events (``execvp`` / ``error_event`` /
33187
+ ``done``). 404 when the worker is uninitialized; the worker's
33188
+ own generator returns immediately on unknown run_id, so the
33189
+ connection closes cleanly.
33190
+ """
33191
+ worker = _UPDATE_WORKER
33192
+ if worker is None:
33193
+ self.send_error(404, "update worker not initialized")
33194
+ return
33195
+ run_id = path.rsplit("/", 1)[-1]
33196
+ self.send_response(200)
33197
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
33198
+ self.send_header("Cache-Control", "no-cache")
33199
+ self.send_header("Connection", "keep-alive")
33200
+ self.send_header("X-Accel-Buffering", "no")
33201
+ self.end_headers()
33202
+ try:
33203
+ for ev in worker.stream(run_id):
33204
+ ev_type = ev.get("type", "message")
33205
+ ev_data = json.dumps(
33206
+ {k: v for k, v in ev.items() if k != "type"},
33207
+ ensure_ascii=False,
33208
+ )
33209
+ msg = f"event: {ev_type}\ndata: {ev_data}\n\n"
33210
+ self.wfile.write(msg.encode("utf-8"))
33211
+ self.wfile.flush()
33212
+ except (BrokenPipeError, ConnectionResetError):
33213
+ # Browser disconnected mid-stream. The worker keeps
33214
+ # running; subsequent reconnects can poll /api/update/status.
33215
+ pass
33216
+
30097
33217
 
30098
33218
  class _TuiSyncThread:
30099
33219
  """Daemon thread that periodically rebuilds the DataSnapshot.
@@ -31947,6 +35067,16 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
31947
35067
  import time as _time
31948
35068
  import webbrowser as _wb
31949
35069
 
35070
+ # Spec §5.7: capture the un-mutated argv + PATH-resolved entrypoint
35071
+ # at boot so the in-place ``execvp`` after a successful update
35072
+ # re-enters the user-facing wrapper (npm Node shim → CCTALLY_PYTHON
35073
+ # honoured; brew symlink → post-upgrade Python script). Module-level
35074
+ # so :class:`UpdateWorker` can read them without plumbing.
35075
+ global ORIGINAL_SYS_ARGV, ORIGINAL_ENTRYPOINT, _UPDATE_WORKER
35076
+ ORIGINAL_SYS_ARGV = list(sys.argv)
35077
+ ORIGINAL_ENTRYPOINT = shutil.which("cctally")
35078
+ _UPDATE_WORKER = UpdateWorker()
35079
+
31950
35080
  # Resolve display tz (--tz overrides config.display.tz). The dashboard's
31951
35081
  # envelope-emitted display block (Tasks 11-12) reads this; for now,
31952
35082
  # stash on args so downstream selectors can pick it up uniformly.
@@ -32081,6 +35211,16 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
32081
35211
  if sync_thread is not None:
32082
35212
  sync_thread.start()
32083
35213
 
35214
+ # Spec §3.5 (codex review fix #5): update-check thread runs even
35215
+ # under --no-sync (frozen-data sessions still surface new versions).
35216
+ # Dedicated stop event so we can join cleanly on shutdown without
35217
+ # relying on the data-sync thread's lifecycle.
35218
+ update_check_stop = threading.Event()
35219
+ update_check_thread = _DashboardUpdateCheckThread(
35220
+ update_check_stop, hub=hub, snapshot_ref=ref,
35221
+ )
35222
+ update_check_thread.start()
35223
+
32084
35224
  # HTTP server on its own thread so the main thread can block on signal.
32085
35225
  srv = ThreadingHTTPServer((args.host, args.port), DashboardHTTPHandler)
32086
35226
  srv.daemon_threads = True # SSE handler threads may block up to 15s on keep-alive timeout — let them die with the process.
@@ -32151,6 +35291,7 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
32151
35291
  finally:
32152
35292
  if sync_thread is not None:
32153
35293
  sync_thread.stop()
35294
+ update_check_stop.set()
32154
35295
  srv.shutdown()
32155
35296
  http_thread.join(timeout=2)
32156
35297
  print("dashboard: stopped", flush=True)
@@ -32561,7 +35702,7 @@ def main(argv: list[str] | None = None) -> int:
32561
35702
  parser.error("a subcommand is required")
32562
35703
  _print_migration_error_banner_if_needed(args)
32563
35704
  try:
32564
- return int(args.func(args))
35705
+ rc = int(args.func(args))
32565
35706
  except KeyboardInterrupt:
32566
35707
  return 130
32567
35708
  except DowngradeDetected as exc:
@@ -32569,7 +35710,44 @@ def main(argv: list[str] | None = None) -> int:
32569
35710
  return 2
32570
35711
  except Exception as exc: # pragma: no cover
32571
35712
  eprint(f"Error: {exc}")
32572
- return 1
35713
+ rc = 1
35714
+ # Post-command update hooks (spec §4.2). Best-effort: any exception
35715
+ # in the banner / background-spawn path must not perturb the parent
35716
+ # command's exit code or perceived output. Runs on both success and
35717
+ # error paths so the user sees pending-update banners even when the
35718
+ # current command failed.
35719
+ try:
35720
+ _post_command_update_hooks(getattr(args, "command", None), args)
35721
+ except Exception:
35722
+ pass
35723
+ return rc
35724
+
35725
+
35726
+ def _post_command_update_hooks(command: str | None, args) -> None:
35727
+ """Render the update banner if applicable + spawn the background
35728
+ refresh check if its TTL has elapsed (spec §4.2 + §3.6).
35729
+
35730
+ Both paths are wrapped in their own try/except by the caller so a
35731
+ failure here can't break the parent command. Reading state /
35732
+ suppress / config is itself best-effort: a corrupt JSON file would
35733
+ raise, but the outer caller swallows it.
35734
+
35735
+ Skip-after-uninstall: ``setup --uninstall`` (with or without
35736
+ ``--purge``) deletes ~/.local/bin/ symlinks and may wipe APP_DIR.
35737
+ Running ``load_config()`` afterwards would recreate APP_DIR via its
35738
+ ``ensure_dirs()`` first-run path, leaving a stub config.json behind
35739
+ and breaking the post-purge "data dir gone" invariant. Skip the
35740
+ hook entirely for that command — the banner is moot post-uninstall
35741
+ anyway."""
35742
+ if command == "setup" and getattr(args, "uninstall", False):
35743
+ return
35744
+ config = load_config()
35745
+ state = _load_update_state()
35746
+ suppress = _load_update_suppress()
35747
+ if _should_show_update_banner(command, args, state, suppress, config):
35748
+ sys.stderr.write(_format_update_banner(state) + "\n")
35749
+ if _is_update_check_due(config):
35750
+ _spawn_background_update_check()
32573
35751
 
32574
35752
 
32575
35753
  if __name__ == "__main__":