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/CHANGELOG.md +15 -30
- package/bin/cctally +3217 -39
- package/bin/cctally-npm-postinstall.js +26 -0
- package/bin/cctally-update +5 -0
- package/dashboard/static/assets/index-D04GnY3n.css +1 -0
- package/dashboard/static/assets/index-Y2WlBP34.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-BQfozCcN.js +0 -12
- package/dashboard/static/assets/index-ee87BMyX.css +0 -1
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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": {
|
|
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).
|
|
24811
|
+
out.append(f"cctally is ready (with {warnings} warning(s) above).")
|
|
22258
24812
|
else:
|
|
22259
|
-
out.append("cctally is ready.
|
|
22260
|
-
out.append("
|
|
22261
|
-
|
|
22262
|
-
|
|
22263
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
22377
|
-
|
|
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
|
|
29547
|
-
|
|
29548
|
-
|
|
29549
|
-
|
|
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
|
|
29560
|
-
is persisted in a single ``save_config`` call inside the
|
|
29561
|
-
``config_writer_lock``. If
|
|
29562
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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__":
|