cctally 1.6.3 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +961 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11061 -34659
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +25 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
|
@@ -0,0 +1,2132 @@
|
|
|
1
|
+
"""Update subsystem for cctally (subcommand + dashboard worker).
|
|
2
|
+
|
|
3
|
+
Eager I/O sibling: bin/cctally loads this at startup. Owns the
|
|
4
|
+
``cctally update`` user-facing surface and the hidden
|
|
5
|
+
``_update-check`` background worker, plus the dashboard's update
|
|
6
|
+
worker / polling thread:
|
|
7
|
+
|
|
8
|
+
- ``cmd_update`` — ``cctally update`` entry point. Routes by mode
|
|
9
|
+
flag (``--check`` / ``--skip`` / ``--remind-later`` / install).
|
|
10
|
+
Mode flags are mutually exclusive; ``--version`` is install-mode
|
|
11
|
+
only. Argparse-enforced for the user-facing surface; the
|
|
12
|
+
dispatcher's redundant check is defense in depth for programmatic
|
|
13
|
+
callers and a clearer error message.
|
|
14
|
+
- ``cmd_update_check_internal`` — hidden ``_update-check``
|
|
15
|
+
subcommand (``argparse.SUPPRESS``ed). The detached-refresh
|
|
16
|
+
worker — not user-facing. Logs lifecycle events to
|
|
17
|
+
``update.log`` and rotates if needed. Always returns 0 (any
|
|
18
|
+
error is logged but the process exits cleanly so the parent
|
|
19
|
+
spawn-and-forget contract holds).
|
|
20
|
+
- ``UpdateError`` + 6 subclasses — typed exception hierarchy
|
|
21
|
+
consumed at the command boundary (``cmd_update``,
|
|
22
|
+
``cmd_update_check_internal``) AND by dashboard ``/api/update*``
|
|
23
|
+
handlers in bin/cctally. ``UpdateValidationError`` (rc=2),
|
|
24
|
+
``UpdateInProgressError`` (carries prior PID), and the four
|
|
25
|
+
``UpdateCheck*`` types (network / rate-limited / HTTP / parse).
|
|
26
|
+
Because the classes are defined here, ``raise`` in moved code
|
|
27
|
+
and ``except`` in moved code both resolve to the SAME class
|
|
28
|
+
object; the eager re-export means dashboard catch sites in
|
|
29
|
+
bin/cctally also see the same object — no class-identity
|
|
30
|
+
mismatch under ``isinstance``/``except``.
|
|
31
|
+
- State/lock/log primitives: ``_load_update_state``,
|
|
32
|
+
``_save_update_state``, ``_load_update_suppress``,
|
|
33
|
+
``_save_update_suppress``, ``_read_lock_pid``,
|
|
34
|
+
``_acquire_update_lock``, ``_release_update_lock``,
|
|
35
|
+
``_rotate_update_log_if_needed``, ``_log_update_event``.
|
|
36
|
+
Atomic write idiom (PID-suffixed tmp + ``os.replace``) +
|
|
37
|
+
schema-versioned-JSON contract per spec §1; mirrors
|
|
38
|
+
``save_config``'s idiom. ``_acquire_update_lock`` uses
|
|
39
|
+
kernel-authoritative ``kill(pid, 0)`` for stale-lock reclaim.
|
|
40
|
+
- Install-method detection (spec §2): ``InstallMethod``
|
|
41
|
+
(``@dataclass(frozen=True)``), ``_resolve_npm_prefix`` (three-tier
|
|
42
|
+
$env → state-file → ``npm prefix -g`` resolution),
|
|
43
|
+
``_detect_install_method`` (path heuristic over
|
|
44
|
+
``realpath(sys.argv[0])``), ``_persist_npm_prefix_to_state``,
|
|
45
|
+
``_persist_install_method_to_state``,
|
|
46
|
+
``_stamp_install_success_to_state`` (post-install state stamp so
|
|
47
|
+
the banner + dashboard auto-close fire immediately),
|
|
48
|
+
``_self_heal_current_version`` (reconciles ``current_version``
|
|
49
|
+
with running binary CHANGELOG; closes the
|
|
50
|
+
``update-state.json lies after manual upgrade`` memory gotcha;
|
|
51
|
+
dev-clone guard via ``.git/`` parent check per issue #42).
|
|
52
|
+
- Version-check pipeline (spec §3): ``_update_user_agent``,
|
|
53
|
+
``_fetch_url`` (typed-exception urllib wrapper),
|
|
54
|
+
``_check_npm_latest_version``, ``_check_brew_latest_version``
|
|
55
|
+
(priority regex chain ``_BREW_VERSION_RE_LIST``),
|
|
56
|
+
``_is_update_check_due`` (TTL gate),
|
|
57
|
+
``_do_update_check`` (the single chokepoint — touches the
|
|
58
|
+
throttle marker FIRST for crash safety),
|
|
59
|
+
``_spawn_background_update_check`` (detached worker spawner),
|
|
60
|
+
``cmd_update_check_internal``.
|
|
61
|
+
- ``--check`` rendering (spec §4.4): ``_format_update_command``,
|
|
62
|
+
``_prerelease_note``, ``_format_update_check_json``,
|
|
63
|
+
``_format_update_check_human``, plus the
|
|
64
|
+
``_UPDATE_METHOD_HUMAN_LABEL`` map and
|
|
65
|
+
``_UPDATE_CHECK_JSON_UNAVAILABLE_ENVELOPE`` for the
|
|
66
|
+
state-unavailable fallback.
|
|
67
|
+
- User-facing flows: ``_do_update_skip`` /
|
|
68
|
+
``_do_update_remind_later`` (suppress mutations),
|
|
69
|
+
``_do_update_check_user`` (user-mode --check that bypasses TTL
|
|
70
|
+
when ``--force``).
|
|
71
|
+
- Install execution (spec §5): ``_preflight_install`` (ordered
|
|
72
|
+
gates: method≠unknown, semver-valid, brew+version reject,
|
|
73
|
+
npm-prefix-writable), ``_build_update_steps`` (brew is two steps
|
|
74
|
+
for diagnostic clarity per §5.2; npm is one), ``_run_streaming``
|
|
75
|
+
(two-thread pump → callbacks + log lines),
|
|
76
|
+
``_do_update_install`` (acquire lock → run steps → release →
|
|
77
|
+
rotate log; dry-run path skips lock + subprocesses),
|
|
78
|
+
``_resolve_execvp_target`` (npm shim re-entry path, spec §5.7).
|
|
79
|
+
- Dashboard surface: ``UpdateWorker`` (single-slot orchestrator,
|
|
80
|
+
spec §5.6; idempotent-release contract per §5.6.1),
|
|
81
|
+
``_DashboardUpdateCheckThread`` (poll cadence ≠ network-call
|
|
82
|
+
frequency; self-heal + TTL probe + SSE republish).
|
|
83
|
+
- Update banner (spec §4.2): ``_args_emit_json`` /
|
|
84
|
+
``_args_emit_machine_stdout`` (the two predicate primitives
|
|
85
|
+
``_should_show_update_banner`` delegates to so a new --json dest
|
|
86
|
+
variant or status-line flag inherits suppression automatically —
|
|
87
|
+
codex finding #8 invariant), ``_semver_gt``,
|
|
88
|
+
``_compute_effective_update_available`` (single source of truth
|
|
89
|
+
for "is there a *real* pending update?" shared by the banner
|
|
90
|
+
predicate AND ``cctally doctor``'s ``safety.update_available``
|
|
91
|
+
check), ``_should_show_update_banner``, ``_format_update_banner``,
|
|
92
|
+
and the ``_UPDATE_BANNER_EXTRA_SUPPRESSED`` set. These were
|
|
93
|
+
specifically called out in Appendix A row #16 as
|
|
94
|
+
over-extracted-then-restored during the _cctally_db split; they
|
|
95
|
+
move with the update vertical here.
|
|
96
|
+
- Update config validators: ``_normalize_update_check_enabled_value``
|
|
97
|
+
/ ``_validate_update_check_ttl_hours_value`` +
|
|
98
|
+
``_UPDATE_CHECK_TTL_HOURS_MIN`` / ``_UPDATE_CHECK_TTL_HOURS_MAX``.
|
|
99
|
+
Consumed by ``_cctally_config`` (CLI ``config get/set/unset``
|
|
100
|
+
+ dashboard ``POST /api/settings``) via the
|
|
101
|
+
``c = _cctally(); c._validate_update_check_ttl_hours_value``
|
|
102
|
+
accessor; eager re-export from this sibling means the cctally
|
|
103
|
+
namespace exposes them unchanged.
|
|
104
|
+
|
|
105
|
+
What stays in bin/cctally:
|
|
106
|
+
- All ``UPDATE_*`` path constants (source-of-truth at L2001-2023);
|
|
107
|
+
consumed via ``c = _cctally(); c.UPDATE_STATE_PATH`` etc. in moved
|
|
108
|
+
code so ``monkeypatch.setitem(ns, "UPDATE_STATE_PATH", tmp)`` in
|
|
109
|
+
``tests/test_update.py`` propagates transparently — no sibling-side
|
|
110
|
+
patches needed. Mirrors Phase D #17/#18 precedent.
|
|
111
|
+
- ``ORIGINAL_SYS_ARGV`` / ``ORIGINAL_ENTRYPOINT`` /
|
|
112
|
+
``_UPDATE_WORKER`` — module-level globals written by
|
|
113
|
+
``cmd_dashboard`` at boot (``global`` statement at L23205);
|
|
114
|
+
read by moved ``_resolve_execvp_target`` and dashboard's
|
|
115
|
+
``/api/update*`` handlers. Stays in cctally so the existing
|
|
116
|
+
write surface in cmd_dashboard works unchanged; moved code
|
|
117
|
+
reads via ``c.X``.
|
|
118
|
+
- ``eprint``, ``_now_utc`` (used by moved code via shim/accessor),
|
|
119
|
+
``_release_read_latest_release_version`` (stays in cctally per
|
|
120
|
+
spec §6.7 — 6+ external callers, file I/O over CHANGELOG.md),
|
|
121
|
+
``_release_parse_semver`` / ``_release_semver_sort_key`` (lives
|
|
122
|
+
in ``_lib_semver`` and re-exported by cctally),
|
|
123
|
+
``load_config`` (lives in ``_cctally_config``; re-exported),
|
|
124
|
+
``_BANNER_SUPPRESSED_COMMANDS`` (lives in ``_cctally_db``;
|
|
125
|
+
re-exported by cctally — composed with the update-only
|
|
126
|
+
``_UPDATE_BANNER_EXTRA_SUPPRESSED`` inside
|
|
127
|
+
``_should_show_update_banner``),
|
|
128
|
+
``CHANGELOG_PATH``, ``PUBLIC_REPO`` (cctally module-level
|
|
129
|
+
constants used in moved code via ``c.X``),
|
|
130
|
+
``_normalize_alerts_enabled_value`` (alerts vertical helper
|
|
131
|
+
reused by update.check.enabled normalizer; stays in cctally per
|
|
132
|
+
task brief).
|
|
133
|
+
- ``SSEHub`` / ``_SnapshotRef`` types referenced only in
|
|
134
|
+
``_DashboardUpdateCheckThread.__init__`` annotations as
|
|
135
|
+
string-typed forward refs — not resolved at runtime, no
|
|
136
|
+
``import cctally`` needed.
|
|
137
|
+
|
|
138
|
+
§5.6 audit on this extraction's monkeypatch surface
|
|
139
|
+
(``tests/test_update.py`` is the primary site; 21 distinct
|
|
140
|
+
``ns["X"]`` symbol-access points + 21 ``monkeypatch.setitem(ns, "X", …)``
|
|
141
|
+
mutation points). Forces the **eager re-export** carve-out
|
|
142
|
+
per spec §4.8 (same precedent as Phase E #19/#20):
|
|
143
|
+
|
|
144
|
+
- ``ns["X"]`` reads on dataclass / function objects propagate
|
|
145
|
+
via eager re-export; PEP 562 ``__getattr__`` does NOT fire on
|
|
146
|
+
``ns["X"]`` dict-key access because ``ns`` is the module's
|
|
147
|
+
``__dict__``, not the module proxy. Re-export at module-load
|
|
148
|
+
time means cctally's ``__dict__`` carries the same object the
|
|
149
|
+
sibling defines.
|
|
150
|
+
- ``monkeypatch.setitem(ns, "X", mock)`` mutates cctally's
|
|
151
|
+
namespace. For a moved symbol that is ALSO called bare-name by
|
|
152
|
+
another moved body (e.g. ``_DashboardUpdateCheckThread.run`` →
|
|
153
|
+
``_do_update_check`` / ``_is_update_check_due`` /
|
|
154
|
+
``_self_heal_current_version`` / ``_load_update_state``;
|
|
155
|
+
``cmd_update_check_internal`` → ``_do_update_check``;
|
|
156
|
+
``_acquire_update_lock`` → ``_read_lock_pid``; etc.), the
|
|
157
|
+
internal bare-name lookup resolves in this sibling's
|
|
158
|
+
``__dict__``, NOT cctally's — so the mock would not propagate.
|
|
159
|
+
Pattern matches Phase D #17/#18: every cross-call from one
|
|
160
|
+
moved function to another moved function that's also a
|
|
161
|
+
monkeypatch target routes through ``c.X`` (alias for
|
|
162
|
+
``sys.modules['cctally'].X``) at call time. The accessor
|
|
163
|
+
resolves at every call so the latest binding wins; mocks
|
|
164
|
+
propagate without sibling-side patches.
|
|
165
|
+
|
|
166
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §7.2
|
|
167
|
+
"""
|
|
168
|
+
from __future__ import annotations
|
|
169
|
+
|
|
170
|
+
import argparse
|
|
171
|
+
import datetime as dt
|
|
172
|
+
import fcntl
|
|
173
|
+
import json
|
|
174
|
+
import os
|
|
175
|
+
import pathlib
|
|
176
|
+
import queue
|
|
177
|
+
import re
|
|
178
|
+
import secrets
|
|
179
|
+
import shlex
|
|
180
|
+
import subprocess
|
|
181
|
+
import sys
|
|
182
|
+
import threading
|
|
183
|
+
import time
|
|
184
|
+
import urllib.error
|
|
185
|
+
import urllib.request
|
|
186
|
+
from dataclasses import dataclass
|
|
187
|
+
from typing import Any, Callable
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cctally():
|
|
191
|
+
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
192
|
+
return sys.modules["cctally"]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
196
|
+
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
197
|
+
# time), so monkeypatches on cctally's namespace propagate into the moved
|
|
198
|
+
# code unchanged. Mirrors the precedent established in
|
|
199
|
+
# ``bin/_cctally_record.py`` (34 shims), ``bin/_cctally_cache.py``
|
|
200
|
+
# (4 shims), and ``bin/_cctally_db.py`` (4 shims).
|
|
201
|
+
def eprint(*args, **kwargs):
|
|
202
|
+
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _now_utc(*args, **kwargs):
|
|
206
|
+
return sys.modules["cctally"]._now_utc(*args, **kwargs)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_config(*args, **kwargs):
|
|
210
|
+
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def save_config(*args, **kwargs):
|
|
214
|
+
return sys.modules["cctally"].save_config(*args, **kwargs)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _release_read_latest_release_version(*args, **kwargs):
|
|
218
|
+
return sys.modules["cctally"]._release_read_latest_release_version(
|
|
219
|
+
*args, **kwargs
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _release_parse_semver(*args, **kwargs):
|
|
224
|
+
return sys.modules["cctally"]._release_parse_semver(*args, **kwargs)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _release_semver_sort_key(*args, **kwargs):
|
|
228
|
+
return sys.modules["cctally"]._release_semver_sort_key(*args, **kwargs)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _normalize_alerts_enabled_value(*args, **kwargs):
|
|
232
|
+
return sys.modules["cctally"]._normalize_alerts_enabled_value(
|
|
233
|
+
*args, **kwargs
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# === Exception hierarchy (spec §1) ========================================
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class UpdateError(Exception):
|
|
241
|
+
"""User-facing error from the update subcommand. Caught at command boundary.
|
|
242
|
+
|
|
243
|
+
Default rc when caught at the boundary is 1 (runtime / environment
|
|
244
|
+
failure: unknown install method, npm prefix not writable, etc.).
|
|
245
|
+
Validation errors (invalid --version syntax, --version+brew combo)
|
|
246
|
+
use the :class:`UpdateValidationError` subclass so the boundary can
|
|
247
|
+
map them to rc=2 — preserving the rc=1-vs-rc=2 distinction the
|
|
248
|
+
Task-4 inline gates exposed.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class UpdateValidationError(UpdateError):
|
|
253
|
+
"""Subclass of UpdateError marking input-validation failures (rc=2).
|
|
254
|
+
|
|
255
|
+
Two cases per spec §5.1: invalid --version syntax (must match
|
|
256
|
+
_SEMVER_RE), and --version with method=brew (no versioned formulae).
|
|
257
|
+
Carved out of UpdateError so cmd_update's try/except can branch on
|
|
258
|
+
type rather than message — the inline gates that Task 4 used both
|
|
259
|
+
returned rc=2; preserving that contract is the test invariant.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class UpdateInProgressError(UpdateError):
|
|
264
|
+
"""Another update is already running. Carries the prior PID for the operator
|
|
265
|
+
message ("Another update is in progress (PID 12345)."). Raised by
|
|
266
|
+
_acquire_update_lock when a live PID still holds update.lock.
|
|
267
|
+
|
|
268
|
+
``prior_pid`` is ``None`` when the lock body was unparseable (no
|
|
269
|
+
``PID=`` line, or non-integer value) — rendered as
|
|
270
|
+
"(PID unknown)" rather than a sentinel like ``0`` (which is a real
|
|
271
|
+
PID in POSIX semantics: the kernel scheduler on Linux)."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, prior_pid: int | None):
|
|
274
|
+
if prior_pid is None:
|
|
275
|
+
super().__init__("Another update is in progress (PID unknown).")
|
|
276
|
+
else:
|
|
277
|
+
super().__init__(f"Another update is in progress (PID {prior_pid}).")
|
|
278
|
+
self.prior_pid = prior_pid
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class UpdateCheckNetworkError(UpdateError):
|
|
282
|
+
"""DNS / connection / non-rate-limit HTTP failure during version check."""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class UpdateCheckRateLimited(UpdateError):
|
|
286
|
+
"""HTTP 429 from npm registry or GitHub raw-content host. Treated as
|
|
287
|
+
non-error (last-known `latest_version` preserved); banner predicate is
|
|
288
|
+
still evaluated against the cached value."""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class UpdateCheckHTTPError(UpdateError):
|
|
292
|
+
"""Non-200, non-429 HTTP status from a version-check endpoint."""
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class UpdateCheckParseError(UpdateError):
|
|
296
|
+
"""Endpoint returned a body we couldn't parse (npm JSON missing
|
|
297
|
+
`version` field, formula ruby missing `version "X.Y.Z"` line)."""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# === update.check config validators ========================================
|
|
301
|
+
# Bounds: 1 hour minimum (avoids accidental DDOS of the registry on a tight
|
|
302
|
+
# loop), 720 hour (= 30 days) maximum. Out-of-range returns ValueError so
|
|
303
|
+
# callers in both the CLI (``_cmd_config_set`` in ``_cctally_config``) and
|
|
304
|
+
# the dashboard (``_handle_post_settings``) can map to their own exit-code /
|
|
305
|
+
# HTTP-status semantics.
|
|
306
|
+
_UPDATE_CHECK_TTL_HOURS_MIN = 1
|
|
307
|
+
_UPDATE_CHECK_TTL_HOURS_MAX = 720
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _normalize_update_check_enabled_value(raw: str) -> bool:
|
|
311
|
+
"""Normalize the CLI string for update.check.enabled. Reuses the
|
|
312
|
+
alerts.enabled string vocabulary so users don't have to remember a
|
|
313
|
+
second set of valid words.
|
|
314
|
+
"""
|
|
315
|
+
try:
|
|
316
|
+
return _normalize_alerts_enabled_value(raw)
|
|
317
|
+
except ValueError:
|
|
318
|
+
# Re-raise with the right key name in the message so the user
|
|
319
|
+
# sees `update.check.enabled` not `alerts.enabled`.
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"invalid boolean value for update.check.enabled: {raw!r} "
|
|
322
|
+
"(expected true|false|yes|no|1|0|on|off)"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _validate_update_check_ttl_hours_value(raw) -> int:
|
|
327
|
+
"""Validate update.check.ttl_hours (int hours). Accepts an int or a
|
|
328
|
+
string of digits; rejects bools (Python ``True`` is an int subclass
|
|
329
|
+
so callers pre-validating JSON shapes must NOT pass a bool through
|
|
330
|
+
here). Range bound: ``[_UPDATE_CHECK_TTL_HOURS_MIN, _MAX]``.
|
|
331
|
+
"""
|
|
332
|
+
if isinstance(raw, bool):
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"invalid value for update.check.ttl_hours: "
|
|
335
|
+
f"{raw!r} (expected integer in "
|
|
336
|
+
f"[{_UPDATE_CHECK_TTL_HOURS_MIN}, {_UPDATE_CHECK_TTL_HOURS_MAX}])"
|
|
337
|
+
)
|
|
338
|
+
if isinstance(raw, int):
|
|
339
|
+
n = raw
|
|
340
|
+
elif isinstance(raw, str):
|
|
341
|
+
s = raw.strip()
|
|
342
|
+
try:
|
|
343
|
+
n = int(s, 10)
|
|
344
|
+
except ValueError:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"invalid integer for update.check.ttl_hours: {raw!r}"
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
"invalid value for update.check.ttl_hours: "
|
|
351
|
+
f"{raw!r} (expected integer in "
|
|
352
|
+
f"[{_UPDATE_CHECK_TTL_HOURS_MIN}, {_UPDATE_CHECK_TTL_HOURS_MAX}])"
|
|
353
|
+
)
|
|
354
|
+
if n < _UPDATE_CHECK_TTL_HOURS_MIN or n > _UPDATE_CHECK_TTL_HOURS_MAX:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
"update.check.ttl_hours out of range: "
|
|
357
|
+
f"{n} (must be in [{_UPDATE_CHECK_TTL_HOURS_MIN}, "
|
|
358
|
+
f"{_UPDATE_CHECK_TTL_HOURS_MAX}])"
|
|
359
|
+
)
|
|
360
|
+
return n
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# === update-subcommand state-file / lock / log helpers (spec §1) =========
|
|
364
|
+
# These live next to load_config / save_config because they share the
|
|
365
|
+
# atomic-write idiom (PID-suffixed tmp + os.replace) and the schema-
|
|
366
|
+
# versioned-JSON contract. Kept stdlib-only per the project's zero-dep
|
|
367
|
+
# ethos.
|
|
368
|
+
|
|
369
|
+
_UPDATE_STATE_SCHEMA_MAX = 1
|
|
370
|
+
_UPDATE_SUPPRESS_SCHEMA_MAX = 1
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _load_update_state() -> dict[str, Any] | None:
|
|
374
|
+
"""Read ``update-state.json``. Returns None when the file is absent
|
|
375
|
+
so callers can distinguish "never checked" from "checked, no update."
|
|
376
|
+
|
|
377
|
+
Raises :class:`UpdateError` when ``_schema`` exceeds the highest
|
|
378
|
+
version this binary knows about — forward-compat invariant from
|
|
379
|
+
spec §1.7. An older cctally must NOT silently drop fields that a
|
|
380
|
+
newer cctally wrote (that would invert the suppress-versions list,
|
|
381
|
+
miss new check_status enum values, etc.).
|
|
382
|
+
|
|
383
|
+
JSON-decode errors also raise :class:`UpdateError`; the writer's
|
|
384
|
+
atomic os.replace guarantees readers never see partial bytes, so a
|
|
385
|
+
parse failure means the file was already corrupt before our read.
|
|
386
|
+
"""
|
|
387
|
+
c = _cctally()
|
|
388
|
+
try:
|
|
389
|
+
text = c.UPDATE_STATE_PATH.read_text(encoding="utf-8")
|
|
390
|
+
except FileNotFoundError:
|
|
391
|
+
return None
|
|
392
|
+
try:
|
|
393
|
+
data = json.loads(text)
|
|
394
|
+
except json.JSONDecodeError as e:
|
|
395
|
+
raise UpdateError(f"update-state.json is not valid JSON: {e}") from e
|
|
396
|
+
if not isinstance(data, dict):
|
|
397
|
+
raise UpdateError(
|
|
398
|
+
f"update-state.json must be a JSON object, got {type(data).__name__}"
|
|
399
|
+
)
|
|
400
|
+
schema = data.get("_schema", 0)
|
|
401
|
+
if not isinstance(schema, int) or schema > _UPDATE_STATE_SCHEMA_MAX:
|
|
402
|
+
raise UpdateError(
|
|
403
|
+
f"update-state.json has _schema={schema!r}; this cctally is older "
|
|
404
|
+
f"than the state file. Upgrade cctally."
|
|
405
|
+
)
|
|
406
|
+
return data
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _save_update_state(state: dict[str, Any]) -> None:
|
|
410
|
+
"""Persist ``update-state.json`` atomically.
|
|
411
|
+
|
|
412
|
+
Mirrors :func:`save_config`: PID-suffixed tmp sibling, fsync the
|
|
413
|
+
bytes, then ``os.replace`` onto the final path. POSIX rename(2) is
|
|
414
|
+
atomic on the same filesystem, so concurrent readers see either the
|
|
415
|
+
pre-rename or post-rename contents but never partial bytes.
|
|
416
|
+
Concurrent writers don't race the bytes themselves but may stomp
|
|
417
|
+
each other's logical updates — the update subcommand serializes
|
|
418
|
+
writers via ``UPDATE_LOCK_PATH`` (spec §5.3).
|
|
419
|
+
"""
|
|
420
|
+
c = _cctally()
|
|
421
|
+
c.UPDATE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
422
|
+
payload = (
|
|
423
|
+
json.dumps(state, indent=2, sort_keys=True) + "\n"
|
|
424
|
+
).encode("utf-8")
|
|
425
|
+
tmp = c.UPDATE_STATE_PATH.with_name(
|
|
426
|
+
f"{c.UPDATE_STATE_PATH.name}.tmp.{os.getpid()}"
|
|
427
|
+
)
|
|
428
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
429
|
+
try:
|
|
430
|
+
os.write(fd, payload)
|
|
431
|
+
os.fsync(fd)
|
|
432
|
+
finally:
|
|
433
|
+
os.close(fd)
|
|
434
|
+
os.replace(str(tmp), str(c.UPDATE_STATE_PATH))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _load_update_suppress() -> dict[str, Any]:
|
|
438
|
+
"""Read ``update-suppress.json``. Returns a default empty record when
|
|
439
|
+
the file is absent (spec §1.3) so the banner predicate doesn't have
|
|
440
|
+
to None-guard every read. Same forward-compat schema check as
|
|
441
|
+
:func:`_load_update_state`.
|
|
442
|
+
"""
|
|
443
|
+
c = _cctally()
|
|
444
|
+
default = {"_schema": 1, "skipped_versions": [], "remind_after": None}
|
|
445
|
+
try:
|
|
446
|
+
text = c.UPDATE_SUPPRESS_PATH.read_text(encoding="utf-8")
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
return default
|
|
449
|
+
try:
|
|
450
|
+
data = json.loads(text)
|
|
451
|
+
except json.JSONDecodeError as e:
|
|
452
|
+
raise UpdateError(
|
|
453
|
+
f"update-suppress.json is not valid JSON: {e}"
|
|
454
|
+
) from e
|
|
455
|
+
if not isinstance(data, dict):
|
|
456
|
+
raise UpdateError(
|
|
457
|
+
f"update-suppress.json must be a JSON object, got "
|
|
458
|
+
f"{type(data).__name__}"
|
|
459
|
+
)
|
|
460
|
+
schema = data.get("_schema", 0)
|
|
461
|
+
if not isinstance(schema, int) or schema > _UPDATE_SUPPRESS_SCHEMA_MAX:
|
|
462
|
+
raise UpdateError(
|
|
463
|
+
f"update-suppress.json has _schema={schema!r}; this cctally is "
|
|
464
|
+
f"older than the suppress file. Upgrade cctally."
|
|
465
|
+
)
|
|
466
|
+
return data
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _save_update_suppress(suppress: dict[str, Any]) -> None:
|
|
470
|
+
"""Persist ``update-suppress.json`` atomically. Same idiom as
|
|
471
|
+
:func:`_save_update_state`."""
|
|
472
|
+
c = _cctally()
|
|
473
|
+
c.UPDATE_SUPPRESS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
474
|
+
payload = (
|
|
475
|
+
json.dumps(suppress, indent=2, sort_keys=True) + "\n"
|
|
476
|
+
).encode("utf-8")
|
|
477
|
+
tmp = c.UPDATE_SUPPRESS_PATH.with_name(
|
|
478
|
+
f"{c.UPDATE_SUPPRESS_PATH.name}.tmp.{os.getpid()}"
|
|
479
|
+
)
|
|
480
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
481
|
+
try:
|
|
482
|
+
os.write(fd, payload)
|
|
483
|
+
os.fsync(fd)
|
|
484
|
+
finally:
|
|
485
|
+
os.close(fd)
|
|
486
|
+
os.replace(str(tmp), str(c.UPDATE_SUPPRESS_PATH))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _read_lock_pid(fd: int) -> int | None:
|
|
490
|
+
"""Parse ``PID=<n>`` out of an open update.lock fd. Returns None on
|
|
491
|
+
any failure (file empty, missing PID line, non-integer value) — the
|
|
492
|
+
caller treats "unknown holder" the same as a stale lock and
|
|
493
|
+
attempts a second LOCK_NB acquire."""
|
|
494
|
+
try:
|
|
495
|
+
os.lseek(fd, 0, 0)
|
|
496
|
+
body = os.read(fd, 1024).decode("utf-8")
|
|
497
|
+
except OSError:
|
|
498
|
+
return None
|
|
499
|
+
for line in body.splitlines():
|
|
500
|
+
if line.startswith("PID="):
|
|
501
|
+
try:
|
|
502
|
+
return int(line[4:])
|
|
503
|
+
except ValueError:
|
|
504
|
+
return None
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _acquire_update_lock() -> int:
|
|
509
|
+
"""Acquire the singleton update.lock under spec §5.3 contract.
|
|
510
|
+
|
|
511
|
+
Returns the open fd on success. Caller MUST pass the fd to
|
|
512
|
+
:func:`_release_update_lock` to drop the flock + unlink the file.
|
|
513
|
+
|
|
514
|
+
Raises :class:`UpdateInProgressError` when a *live* PID still holds
|
|
515
|
+
the lock. Stale locks (writer crashed without releasing) are
|
|
516
|
+
silently reclaimed: ``kill(pid, 0)`` raising ``ProcessLookupError``
|
|
517
|
+
is the only signal we trust for reclaim — kernel-authoritative,
|
|
518
|
+
free of read-the-file-then-stat races.
|
|
519
|
+
|
|
520
|
+
Body format (text, line-oriented for ``cat update.lock``)::
|
|
521
|
+
|
|
522
|
+
PID=12345
|
|
523
|
+
STARTED_AT_UTC=2026-05-10T13:05:23+00:00
|
|
524
|
+
COMMAND=cctally update
|
|
525
|
+
"""
|
|
526
|
+
c = _cctally()
|
|
527
|
+
c.UPDATE_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
fd = os.open(
|
|
529
|
+
str(c.UPDATE_LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o644
|
|
530
|
+
)
|
|
531
|
+
try:
|
|
532
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
533
|
+
except BlockingIOError:
|
|
534
|
+
# _read_lock_pid is module-local (no monkeypatch surface — its
|
|
535
|
+
# contract is pure file-read on the supplied fd), so the bare
|
|
536
|
+
# name is fine here.
|
|
537
|
+
prior = _read_lock_pid(fd)
|
|
538
|
+
if prior is not None:
|
|
539
|
+
try:
|
|
540
|
+
os.kill(prior, 0)
|
|
541
|
+
except ProcessLookupError:
|
|
542
|
+
pass # stale → fall through to reclaim attempt
|
|
543
|
+
else:
|
|
544
|
+
# Live PID still holds the lock — refuse.
|
|
545
|
+
os.close(fd)
|
|
546
|
+
raise UpdateInProgressError(prior)
|
|
547
|
+
# Stale (or unparseable PID): retry the non-blocking acquire.
|
|
548
|
+
# If it still fails, another process raced us into the same
|
|
549
|
+
# reclaim path; surface it as in-progress with the best PID
|
|
550
|
+
# we observed (or 0 if we couldn't read one).
|
|
551
|
+
try:
|
|
552
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
553
|
+
except BlockingIOError:
|
|
554
|
+
os.close(fd)
|
|
555
|
+
raise UpdateInProgressError(prior)
|
|
556
|
+
os.ftruncate(fd, 0)
|
|
557
|
+
body = (
|
|
558
|
+
f"PID={os.getpid()}\n"
|
|
559
|
+
f"STARTED_AT_UTC={_now_utc().isoformat()}\n"
|
|
560
|
+
f"COMMAND=cctally update\n"
|
|
561
|
+
).encode("utf-8")
|
|
562
|
+
os.write(fd, body)
|
|
563
|
+
return fd
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _release_update_lock(fd: int) -> None:
|
|
567
|
+
"""Drop the flock and close the fd. The lock file persists.
|
|
568
|
+
|
|
569
|
+
Defensive on every step: a double-release (or a release after the
|
|
570
|
+
fd has been closed by an earlier error path) must not raise.
|
|
571
|
+
|
|
572
|
+
The file at ``UPDATE_LOCK_PATH`` is deliberately NOT unlinked.
|
|
573
|
+
``flock`` locks the inode behind the fd, not the path: unlinking
|
|
574
|
+
after release lets a peer that ``O_CREAT``ed a new inode at the
|
|
575
|
+
same path hold a "lock" on a different inode from a peer that
|
|
576
|
+
still references the old one — concurrent updates. Leaving the
|
|
577
|
+
file in place pins all acquires to a single inode; the kernel's
|
|
578
|
+
flock state is the sole synchronization primitive. ``_acquire_..``
|
|
579
|
+
handles the persistent-file case (O_CREAT + ftruncate + rewrite
|
|
580
|
+
on every acquire).
|
|
581
|
+
"""
|
|
582
|
+
try:
|
|
583
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
584
|
+
except OSError:
|
|
585
|
+
pass
|
|
586
|
+
try:
|
|
587
|
+
os.close(fd)
|
|
588
|
+
except OSError:
|
|
589
|
+
pass
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _rotate_update_log_if_needed() -> None:
|
|
593
|
+
"""Rotate ``update.log`` → ``update.log.1`` when the live log
|
|
594
|
+
crosses :data:`UPDATE_LOG_ROTATE_BYTES` (1 MB, spec §1.5).
|
|
595
|
+
|
|
596
|
+
Single rotation slot: a second rotation overwrites the first.
|
|
597
|
+
Failed-install logs are preserved on disk only until the next
|
|
598
|
+
successful run grows the live log past 1 MB — operators chasing a
|
|
599
|
+
historical failure should grab ``update.log.1`` while it's still
|
|
600
|
+
around.
|
|
601
|
+
|
|
602
|
+
No-op when the file is absent or below threshold.
|
|
603
|
+
"""
|
|
604
|
+
c = _cctally()
|
|
605
|
+
try:
|
|
606
|
+
size = c.UPDATE_LOG_PATH.stat().st_size
|
|
607
|
+
except FileNotFoundError:
|
|
608
|
+
return
|
|
609
|
+
if size < c.UPDATE_LOG_ROTATE_BYTES:
|
|
610
|
+
return
|
|
611
|
+
try:
|
|
612
|
+
c.UPDATE_LOG_ROTATED_PATH.unlink()
|
|
613
|
+
except FileNotFoundError:
|
|
614
|
+
pass
|
|
615
|
+
c.UPDATE_LOG_PATH.rename(c.UPDATE_LOG_ROTATED_PATH)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _log_update_event(log_fd, event: str, **kv: Any) -> None:
|
|
619
|
+
"""Append one event line to ``update.log``.
|
|
620
|
+
|
|
621
|
+
Format: ``<iso-utc> <EVENT> k=v k=v ...``. Strings containing
|
|
622
|
+
spaces are wrapped with ``repr`` so the log stays grep-friendly;
|
|
623
|
+
integers are emitted bare so size/elapsed columns can be
|
|
624
|
+
arithmetic-parsed. ``log_fd`` is any text-mode writable file-like
|
|
625
|
+
(``open(UPDATE_LOG_PATH, "a", encoding="utf-8")`` is the production
|
|
626
|
+
caller from Task 5).
|
|
627
|
+
"""
|
|
628
|
+
parts = [_now_utc().isoformat(), event]
|
|
629
|
+
for k, v in kv.items():
|
|
630
|
+
if isinstance(v, str) and " " in v:
|
|
631
|
+
parts.append(f"{k}={v!r}")
|
|
632
|
+
else:
|
|
633
|
+
parts.append(f"{k}={v}")
|
|
634
|
+
log_fd.write(" ".join(parts) + "\n")
|
|
635
|
+
log_fd.flush()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# === Update subcommand: install-method detection (spec §2) =================
|
|
639
|
+
# Path-based heuristic over `realpath(sys.argv[0])`:
|
|
640
|
+
# - "/Cellar/cctally/" substring → method="brew" (Apple Silicon, Intel,
|
|
641
|
+
# and Linuxbrew all funnel through `<root>/Cellar/cctally/`).
|
|
642
|
+
# - "<npm-prefix>/lib/node_modules/cctally/" prefix → method="npm".
|
|
643
|
+
# - Anything else (source install, pnpm/yarn-global/volta, dev symlink)
|
|
644
|
+
# → method="unknown" → manual-fallback bucket per spec §2.4.
|
|
645
|
+
# `mutate=False` is the dry-run contract (§5.5): every tier still
|
|
646
|
+
# computes, but tier-C cache writes to update-state.json are skipped.
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@dataclass(frozen=True)
|
|
650
|
+
class InstallMethod:
|
|
651
|
+
"""Resolved install method for the running cctally binary (spec §2.1).
|
|
652
|
+
|
|
653
|
+
``method`` is one of ``"brew"``, ``"npm"``, ``"unknown"``;
|
|
654
|
+
``realpath`` is ``os.path.realpath(sys.argv[0])`` (the resolved
|
|
655
|
+
target of any symlinks on $PATH); ``npm_prefix`` is populated only
|
|
656
|
+
when ``method == "npm"`` so callers don't have to special-case it.
|
|
657
|
+
"""
|
|
658
|
+
|
|
659
|
+
method: str
|
|
660
|
+
realpath: str
|
|
661
|
+
npm_prefix: str | None
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _resolve_npm_prefix(*, mutate: bool = True) -> str | None:
|
|
665
|
+
"""Three-tier ``npm prefix -g`` resolution (spec §2.2).
|
|
666
|
+
|
|
667
|
+
Tier A: ``$npm_config_prefix`` env var (rarely set; free).
|
|
668
|
+
Tier B: cached ``install.npm_prefix`` from update-state.json,
|
|
669
|
+
7-day TTL (one ``os.stat`` via ``_load_update_state``).
|
|
670
|
+
Tier C: ``subprocess.run(["npm", "prefix", "-g"], timeout=2.0)``
|
|
671
|
+
(200–300 ms cold). Tier-C success populates tier-B only when
|
|
672
|
+
``mutate=True``; failure (npm not on PATH, timeout, non-zero
|
|
673
|
+
exit) returns ``None`` regardless of ``mutate``.
|
|
674
|
+
"""
|
|
675
|
+
c = _cctally()
|
|
676
|
+
# Tier A — env var short-circuit.
|
|
677
|
+
env_pref = os.environ.get("npm_config_prefix")
|
|
678
|
+
if env_pref and pathlib.Path(env_pref).is_dir():
|
|
679
|
+
return env_pref
|
|
680
|
+
# Tier B — cached state-file value within 7-day TTL.
|
|
681
|
+
# `_load_update_state` routed through cctally so a
|
|
682
|
+
# `monkeypatch.setitem(ns, "_load_update_state", mock)` propagates.
|
|
683
|
+
state = c._load_update_state()
|
|
684
|
+
if state and isinstance(state.get("install"), dict):
|
|
685
|
+
cached = state["install"].get("npm_prefix")
|
|
686
|
+
detected_iso = state["install"].get("detected_at_utc")
|
|
687
|
+
if cached and detected_iso:
|
|
688
|
+
try:
|
|
689
|
+
detected = dt.datetime.fromisoformat(detected_iso)
|
|
690
|
+
age = (_now_utc() - detected).total_seconds()
|
|
691
|
+
if age < c.UPDATE_NPM_PREFIX_TTL_DAYS * 86400:
|
|
692
|
+
return cached
|
|
693
|
+
except (ValueError, TypeError):
|
|
694
|
+
# Malformed timestamp → fall through to tier C.
|
|
695
|
+
pass
|
|
696
|
+
# Tier C — subprocess. Treat any failure as "unknown npm prefix"
|
|
697
|
+
# rather than raising; the caller maps None → method="unknown".
|
|
698
|
+
try:
|
|
699
|
+
result = subprocess.run(
|
|
700
|
+
["npm", "prefix", "-g"],
|
|
701
|
+
timeout=c.UPDATE_NPM_PREFIX_TIMEOUT_S,
|
|
702
|
+
capture_output=True,
|
|
703
|
+
text=True,
|
|
704
|
+
)
|
|
705
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
706
|
+
return None
|
|
707
|
+
if result.returncode != 0:
|
|
708
|
+
return None
|
|
709
|
+
prefix = result.stdout.strip()
|
|
710
|
+
if not prefix:
|
|
711
|
+
return None
|
|
712
|
+
if mutate:
|
|
713
|
+
c._persist_npm_prefix_to_state(prefix)
|
|
714
|
+
return prefix
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _persist_npm_prefix_to_state(prefix: str) -> None:
|
|
718
|
+
"""Write ``install.npm_prefix`` + ``install.detected_at_utc`` to
|
|
719
|
+
update-state.json, preserving every other field. Used only by
|
|
720
|
+
tier-C of :func:`_resolve_npm_prefix` when ``mutate=True``.
|
|
721
|
+
"""
|
|
722
|
+
c = _cctally()
|
|
723
|
+
state = c._load_update_state() or {"_schema": 1}
|
|
724
|
+
state.setdefault("install", {})
|
|
725
|
+
state["install"]["npm_prefix"] = prefix
|
|
726
|
+
state["install"]["detected_at_utc"] = _now_utc().isoformat()
|
|
727
|
+
c._save_update_state(state)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _detect_install_method(*, mutate: bool = True) -> InstallMethod:
|
|
731
|
+
"""Detect how the running cctally was installed (spec §2.1).
|
|
732
|
+
|
|
733
|
+
Path-based heuristic — see module-level comment above
|
|
734
|
+
:class:`InstallMethod` for the algorithm. ``mutate=False`` honours
|
|
735
|
+
the ``--dry-run`` "touch nothing" contract: detection still runs,
|
|
736
|
+
but neither the npm-prefix tier-B cache nor the install block is
|
|
737
|
+
persisted to update-state.json.
|
|
738
|
+
"""
|
|
739
|
+
c = _cctally()
|
|
740
|
+
real = os.path.realpath(sys.argv[0])
|
|
741
|
+
if "/Cellar/cctally/" in real:
|
|
742
|
+
method = InstallMethod(method="brew", realpath=real, npm_prefix=None)
|
|
743
|
+
else:
|
|
744
|
+
prefix = c._resolve_npm_prefix(mutate=mutate)
|
|
745
|
+
if prefix:
|
|
746
|
+
nm_root = os.path.join(prefix, "lib", "node_modules", "cctally")
|
|
747
|
+
if real == nm_root or real.startswith(nm_root + os.sep):
|
|
748
|
+
method = InstallMethod(
|
|
749
|
+
method="npm", realpath=real, npm_prefix=prefix
|
|
750
|
+
)
|
|
751
|
+
else:
|
|
752
|
+
method = InstallMethod(
|
|
753
|
+
method="unknown", realpath=real, npm_prefix=None
|
|
754
|
+
)
|
|
755
|
+
else:
|
|
756
|
+
method = InstallMethod(
|
|
757
|
+
method="unknown", realpath=real, npm_prefix=None
|
|
758
|
+
)
|
|
759
|
+
if mutate:
|
|
760
|
+
c._persist_install_method_to_state(method)
|
|
761
|
+
return method
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _persist_install_method_to_state(method: InstallMethod) -> None:
|
|
765
|
+
"""Replace the ``install`` block in update-state.json with a fresh
|
|
766
|
+
detection result, preserving every other field (e.g. ``latest_version``
|
|
767
|
+
written by the version-check pipeline in Task 3). ``current_version``
|
|
768
|
+
is also re-stamped from the CHANGELOG so the running binary's
|
|
769
|
+
self-version stays in sync with the install block.
|
|
770
|
+
"""
|
|
771
|
+
c = _cctally()
|
|
772
|
+
state = c._load_update_state() or {"_schema": 1}
|
|
773
|
+
state["install"] = {
|
|
774
|
+
"method": method.method,
|
|
775
|
+
"realpath": method.realpath,
|
|
776
|
+
"npm_prefix": method.npm_prefix,
|
|
777
|
+
"detected_at_utc": _now_utc().isoformat(),
|
|
778
|
+
}
|
|
779
|
+
cur = _release_read_latest_release_version()
|
|
780
|
+
if cur:
|
|
781
|
+
state["current_version"] = cur[0]
|
|
782
|
+
c._save_update_state(state)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _stamp_install_success_to_state(
|
|
786
|
+
installed_version: str | None,
|
|
787
|
+
method: "InstallMethod | None" = None,
|
|
788
|
+
) -> None:
|
|
789
|
+
"""Stamp ``update-state.json`` with the just-installed version so the
|
|
790
|
+
post-install banner predicate + dashboard auto-close fire immediately.
|
|
791
|
+
|
|
792
|
+
Without this, both surfaces are stuck for up to ``ttl_hours`` (24h
|
|
793
|
+
default): ``_do_update_check`` touched the throttle marker before
|
|
794
|
+
install began, so ``_is_update_check_due`` returns False on every
|
|
795
|
+
subsequent boot until the TTL expires; ``current_version`` would
|
|
796
|
+
keep its pre-install value, and ``_semver_gt(latest, current)``
|
|
797
|
+
stays True. Banner re-fires on every CLI command; dashboard's
|
|
798
|
+
``refreshUpdateState`` auto-close (``current === latest``) never
|
|
799
|
+
matches.
|
|
800
|
+
|
|
801
|
+
Resolution order:
|
|
802
|
+
1. ``installed_version`` — caller passed an explicit ``--version``.
|
|
803
|
+
2. For brew (when ``method.method == "brew"`` and no explicit
|
|
804
|
+
version), ``state.latest_version`` — the freshly-probed value
|
|
805
|
+
that drove the install. The running process's ``CHANGELOG_PATH``
|
|
806
|
+
resolved to the OLD Cellar at boot, so a CHANGELOG read here
|
|
807
|
+
returns the pre-upgrade version and would stamp the wrong
|
|
808
|
+
``current_version`` until the next dashboard self-heal (up to
|
|
809
|
+
30 min) or the next CLI invocation. The stale-probe regression
|
|
810
|
+
that pushed CHANGELOG ahead of ``latest_version`` (1.6.0-after-
|
|
811
|
+
installing-1.6.3) does not apply on the brew path: brew's
|
|
812
|
+
install probe ran inside the user's just-issued
|
|
813
|
+
``cctally update``, so ``latest_version`` is current.
|
|
814
|
+
3. Freshly-installed CHANGELOG (``_release_read_latest_release_version``).
|
|
815
|
+
For npm the install overwrites ``CHANGELOG.md`` in place, so
|
|
816
|
+
this read inside the same Python process returns the just-
|
|
817
|
+
installed version. Skipped on brew for the reason above.
|
|
818
|
+
4. ``state.latest_version`` — last resort, also covers the npm
|
|
819
|
+
path when CHANGELOG is unreadable.
|
|
820
|
+
"""
|
|
821
|
+
c = _cctally()
|
|
822
|
+
state = c._load_update_state() or {"_schema": 1}
|
|
823
|
+
cur = installed_version
|
|
824
|
+
if cur is None and method is not None and method.method == "brew":
|
|
825
|
+
# Brew: prefer the cached probe (just observed by `cctally
|
|
826
|
+
# update`) over CHANGELOG, which reads from the OLD Cellar.
|
|
827
|
+
cur = state.get("latest_version")
|
|
828
|
+
if cur is None:
|
|
829
|
+
fresh = _release_read_latest_release_version()
|
|
830
|
+
if fresh:
|
|
831
|
+
cur = fresh[0]
|
|
832
|
+
if cur is None:
|
|
833
|
+
cur = state.get("latest_version")
|
|
834
|
+
if cur:
|
|
835
|
+
state["current_version"] = cur
|
|
836
|
+
state["last_install_success_at_utc"] = _now_utc().isoformat()
|
|
837
|
+
c._save_update_state(state)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _self_heal_current_version() -> None:
|
|
841
|
+
"""Reconcile ``update-state.json``'s ``current_version`` with the
|
|
842
|
+
running binary's CHANGELOG when they disagree.
|
|
843
|
+
|
|
844
|
+
Closes the documented gap (memory:
|
|
845
|
+
``gotcha_update_state_cache_lies_after_version_bump``) where a user
|
|
846
|
+
upgrades via ``npm install -g cctally@latest`` (or any out-of-band
|
|
847
|
+
path that bypasses ``cctally update``) and ``current_version``
|
|
848
|
+
stays frozen on the pre-upgrade value until the next TTL probe
|
|
849
|
+
fires (24h default). The dashboard's brand-version label and the
|
|
850
|
+
CLI banner predicate both read ``current_version``, so users see
|
|
851
|
+
a stale "you're on <old>" indefinitely.
|
|
852
|
+
|
|
853
|
+
Best-effort: any failure — state missing/corrupt, CHANGELOG
|
|
854
|
+
unreadable, save fails — is silently swallowed. The caller is in
|
|
855
|
+
a post-command hook and must never break the parent command.
|
|
856
|
+
|
|
857
|
+
Why not bootstrap when state is missing: a ``None`` state means no
|
|
858
|
+
update probe has ever run, so we have no ``latest_version`` /
|
|
859
|
+
``install`` block to seed alongside ``current_version``. Writing a
|
|
860
|
+
partial state would mask the missing-probe condition that
|
|
861
|
+
``_check_safety_update_state`` and the doctor report rely on; the
|
|
862
|
+
next ``_do_update_check`` creates the file fully.
|
|
863
|
+
|
|
864
|
+
Dev-clone guard (issue #42): when ``CHANGELOG_PATH``'s parent
|
|
865
|
+
contains a ``.git/`` directory, the running binary is a development
|
|
866
|
+
checkout, not the globally-installed one. The CHANGELOG describes
|
|
867
|
+
the working tree (e.g. a release-cut Phase 1 stamp), so stamping
|
|
868
|
+
the global state from it would corrupt ``current_version`` to a
|
|
869
|
+
version that is NOT what is installed. Production tarballs (npm
|
|
870
|
+
tar, brew archive) never ship ``.git/``, so this heuristic only
|
|
871
|
+
ever skips dev clones; legitimate out-of-band upgrades on npm/brew
|
|
872
|
+
still self-heal as before.
|
|
873
|
+
"""
|
|
874
|
+
c = _cctally()
|
|
875
|
+
try:
|
|
876
|
+
if (c.CHANGELOG_PATH.parent / ".git").exists():
|
|
877
|
+
return
|
|
878
|
+
fresh = _release_read_latest_release_version()
|
|
879
|
+
if fresh is None:
|
|
880
|
+
return
|
|
881
|
+
running = fresh[0]
|
|
882
|
+
state = c._load_update_state()
|
|
883
|
+
if state is None:
|
|
884
|
+
return
|
|
885
|
+
if state.get("current_version") == running:
|
|
886
|
+
return
|
|
887
|
+
state["current_version"] = running
|
|
888
|
+
c._save_update_state(state)
|
|
889
|
+
except Exception:
|
|
890
|
+
pass
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# === Update subcommand: version-check pipeline (spec §3) ====================
|
|
894
|
+
# Per-vector parsers, TTL gate, and the chokepoint `_do_update_check` that
|
|
895
|
+
# touches the throttle marker FIRST (crash safety) before attempting any
|
|
896
|
+
# remote fetch. Failures preserve the prior state's `latest_version` so the
|
|
897
|
+
# banner predicate can still fire on the last-known-good value.
|
|
898
|
+
|
|
899
|
+
# Priority regex chain for `_check_brew_latest_version`. First match wins:
|
|
900
|
+
# 1. Explicit `version "X.Y.Z"` line (homebrew's preferred form).
|
|
901
|
+
# 2. Archive URL `/vX.Y.Z[-prerelease.N].tar` (auto-archive form).
|
|
902
|
+
# 3. Tag form `tag: "[v]X.Y.Z"` (occasionally seen in head/url blocks).
|
|
903
|
+
_BREW_VERSION_RE_LIST = (
|
|
904
|
+
re.compile(r'^\s*version\s+"([^"]+)"\s*$', re.MULTILINE),
|
|
905
|
+
re.compile(
|
|
906
|
+
r'url\s+"[^"]*/v(\d+\.\d+\.\d+(?:-[a-zA-Z][a-zA-Z0-9-]*\.\d+)?)\.tar',
|
|
907
|
+
re.MULTILINE,
|
|
908
|
+
),
|
|
909
|
+
re.compile(
|
|
910
|
+
r'tag:\s*"v?(\d+\.\d+\.\d+(?:-[a-zA-Z][a-zA-Z0-9-]*\.\d+)?)"',
|
|
911
|
+
re.MULTILINE,
|
|
912
|
+
),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _update_user_agent() -> str:
|
|
917
|
+
"""User-Agent for `_fetch_url` HTTP requests.
|
|
918
|
+
|
|
919
|
+
Format: ``cctally-update-check/<version>``. Sources the version from
|
|
920
|
+
the CHANGELOG (same chokepoint as every other "what version am I"
|
|
921
|
+
callsite); falls back to ``"dev"`` for pre-release / unstamped trees.
|
|
922
|
+
"""
|
|
923
|
+
cur = _release_read_latest_release_version()
|
|
924
|
+
ver = cur[0] if cur else "dev"
|
|
925
|
+
return f"cctally-update-check/{ver}"
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _fetch_url(url: str, *, timeout: float | None = None) -> tuple[int, bytes]:
|
|
929
|
+
"""Stdlib urllib HTTP GET. Raises typed exceptions on failure.
|
|
930
|
+
|
|
931
|
+
Returns ``(status_code, body_bytes)`` on success. Maps urllib failures
|
|
932
|
+
to the four `UpdateCheck*` exception types so callers can distinguish
|
|
933
|
+
"rate-limited (try again later)" from "HTTP fetch failed (treat as
|
|
934
|
+
last-known-good)" from "DNS / network down".
|
|
935
|
+
"""
|
|
936
|
+
c = _cctally()
|
|
937
|
+
if timeout is None:
|
|
938
|
+
timeout = c.UPDATE_NETWORK_TIMEOUT_S
|
|
939
|
+
req = urllib.request.Request(url, headers={
|
|
940
|
+
"User-Agent": _update_user_agent(),
|
|
941
|
+
"Accept": "*/*",
|
|
942
|
+
})
|
|
943
|
+
try:
|
|
944
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
945
|
+
return (resp.status, resp.read())
|
|
946
|
+
except urllib.error.HTTPError as e:
|
|
947
|
+
if e.code == 429:
|
|
948
|
+
raise UpdateCheckRateLimited(str(e))
|
|
949
|
+
raise UpdateCheckHTTPError(f"HTTP {e.code}: {e}")
|
|
950
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
951
|
+
# URLError covers connection-setup failures; TimeoutError
|
|
952
|
+
# (socket.timeout's alias since 3.10) covers stalls during
|
|
953
|
+
# resp.read() — that path raises directly through http.client
|
|
954
|
+
# without urllib wrapping. Both must funnel to
|
|
955
|
+
# UpdateCheckNetworkError so _do_update_check translates them
|
|
956
|
+
# into check_status="fetch_failed" instead of letting a slow
|
|
957
|
+
# registry crash --check with an uncaught traceback.
|
|
958
|
+
raise UpdateCheckNetworkError(str(e))
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _check_npm_latest_version() -> str:
|
|
962
|
+
"""Fetch the npm-registry `latest` JSON and return its `version` field.
|
|
963
|
+
|
|
964
|
+
Endpoint: :data:`UPDATE_NPM_REGISTRY_URL` (env-overridable via
|
|
965
|
+
``CCTALLY_TEST_UPDATE_NPM_URL`` for fixture testing). JSON decode
|
|
966
|
+
errors and missing-key errors raise :class:`UpdateCheckParseError`.
|
|
967
|
+
"""
|
|
968
|
+
c = _cctally()
|
|
969
|
+
status, body = c._fetch_url(c.UPDATE_NPM_REGISTRY_URL)
|
|
970
|
+
try:
|
|
971
|
+
data = json.loads(body.decode("utf-8"))
|
|
972
|
+
return data["version"]
|
|
973
|
+
except (json.JSONDecodeError, KeyError, UnicodeDecodeError) as e:
|
|
974
|
+
raise UpdateCheckParseError(f"npm registry parse failed: {e}")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _check_brew_latest_version() -> str:
|
|
978
|
+
"""Fetch the brew formula raw blob and extract the version.
|
|
979
|
+
|
|
980
|
+
Endpoint: :data:`UPDATE_BREW_FORMULA_URL`. Applies
|
|
981
|
+
:data:`_BREW_VERSION_RE_LIST` in priority order; first match wins.
|
|
982
|
+
No regex matches → :class:`UpdateCheckParseError`.
|
|
983
|
+
"""
|
|
984
|
+
c = _cctally()
|
|
985
|
+
status, body = c._fetch_url(c.UPDATE_BREW_FORMULA_URL)
|
|
986
|
+
try:
|
|
987
|
+
text = body.decode("utf-8")
|
|
988
|
+
except UnicodeDecodeError as e:
|
|
989
|
+
raise UpdateCheckParseError(f"brew formula decode failed: {e}")
|
|
990
|
+
for pattern in _BREW_VERSION_RE_LIST:
|
|
991
|
+
m = pattern.search(text)
|
|
992
|
+
if m:
|
|
993
|
+
return m.group(1)
|
|
994
|
+
raise UpdateCheckParseError("brew formula version not found")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _is_update_check_due(config: dict) -> bool:
|
|
998
|
+
"""TTL gate (spec §3.4).
|
|
999
|
+
|
|
1000
|
+
Reads ``update.check.enabled`` (default True) and
|
|
1001
|
+
``update.check.ttl_hours`` (default :data:`UPDATE_DEFAULT_TTL_HOURS`)
|
|
1002
|
+
from the config. Returns False if disabled. Returns True if the
|
|
1003
|
+
throttle marker (:data:`UPDATE_CHECK_LAST_FETCH_PATH`) is missing.
|
|
1004
|
+
Otherwise: ``(now - mtime) >= ttl * 3600``.
|
|
1005
|
+
"""
|
|
1006
|
+
c = _cctally()
|
|
1007
|
+
check_cfg = (config.get("update", {}) or {}).get("check", {}) or {}
|
|
1008
|
+
enabled = check_cfg.get("enabled", True)
|
|
1009
|
+
if not enabled:
|
|
1010
|
+
return False
|
|
1011
|
+
ttl_hours = check_cfg.get("ttl_hours", c.UPDATE_DEFAULT_TTL_HOURS)
|
|
1012
|
+
try:
|
|
1013
|
+
mtime = c.UPDATE_CHECK_LAST_FETCH_PATH.stat().st_mtime
|
|
1014
|
+
except FileNotFoundError:
|
|
1015
|
+
return True
|
|
1016
|
+
return (time.time() - mtime) >= ttl_hours * 3600
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _do_update_check() -> None:
|
|
1020
|
+
"""Single chokepoint for a version-check fetch (spec §3.5).
|
|
1021
|
+
|
|
1022
|
+
Touches the throttle marker FIRST (crash-safety: if the process
|
|
1023
|
+
dies mid-fetch, we still won't refetch for the full TTL window —
|
|
1024
|
+
avoids hammering the registry on a flapping host). Then resolves
|
|
1025
|
+
install method, ensures `current_version` is stamped from CHANGELOG,
|
|
1026
|
+
preserves prior `latest_version` if any, and dispatches to the
|
|
1027
|
+
per-vector check by `method.method`. On success: write
|
|
1028
|
+
`check_status="ok"` + `latest_version_url`. On failure: map the
|
|
1029
|
+
typed exception to a `check_status` enum (`rate_limited` /
|
|
1030
|
+
`fetch_failed` / `parse_failed`); never lose the prior
|
|
1031
|
+
`latest_version`. State is saved unconditionally on the way out.
|
|
1032
|
+
"""
|
|
1033
|
+
c = _cctally()
|
|
1034
|
+
# Touch marker FIRST — crash safety: a dead process mid-fetch must
|
|
1035
|
+
# not trigger another fetch within the TTL window.
|
|
1036
|
+
c.UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1037
|
+
c.UPDATE_CHECK_LAST_FETCH_PATH.touch()
|
|
1038
|
+
|
|
1039
|
+
method = c._detect_install_method(mutate=True)
|
|
1040
|
+
|
|
1041
|
+
state = c._load_update_state() or {"_schema": 1}
|
|
1042
|
+
cur = _release_read_latest_release_version()
|
|
1043
|
+
if cur:
|
|
1044
|
+
state["current_version"] = cur[0]
|
|
1045
|
+
# Preserve prior `latest_version`; default to current_version if
|
|
1046
|
+
# nothing was ever recorded (so banner predicate has a comparable).
|
|
1047
|
+
state.setdefault("latest_version", state.get("current_version"))
|
|
1048
|
+
state["checked_at_utc"] = _now_utc().isoformat()
|
|
1049
|
+
state["check_error"] = None
|
|
1050
|
+
|
|
1051
|
+
try:
|
|
1052
|
+
if method.method == "npm":
|
|
1053
|
+
latest = c._check_npm_latest_version()
|
|
1054
|
+
state["latest_version"] = latest
|
|
1055
|
+
state["source"] = "npm-registry"
|
|
1056
|
+
elif method.method == "brew":
|
|
1057
|
+
latest = c._check_brew_latest_version()
|
|
1058
|
+
state["latest_version"] = latest
|
|
1059
|
+
state["source"] = "github-formula"
|
|
1060
|
+
else:
|
|
1061
|
+
# Unknown install method — no remote check possible
|
|
1062
|
+
# (manual-fallback bucket per §2.4). Reset `latest_version`
|
|
1063
|
+
# to `current_version` so the banner predicate's
|
|
1064
|
+
# `_semver_gt(lat, cur)` returns False; preserving a prior
|
|
1065
|
+
# npm/brew latest here would advertise an update that
|
|
1066
|
+
# `cctally update` cannot apply (install method is now
|
|
1067
|
+
# unknown). The setdefault above is insufficient because
|
|
1068
|
+
# state may already carry a `latest_version` from an
|
|
1069
|
+
# earlier npm/brew install before the user switched to a
|
|
1070
|
+
# source checkout. Same suppression flows to the dashboard
|
|
1071
|
+
# amber badge, which reads `latest_version` directly.
|
|
1072
|
+
state["latest_version"] = state.get("current_version")
|
|
1073
|
+
state["check_status"] = "unavailable"
|
|
1074
|
+
c._save_update_state(state)
|
|
1075
|
+
return
|
|
1076
|
+
# Success: build the public-mirror release tag URL.
|
|
1077
|
+
state["latest_version_url"] = (
|
|
1078
|
+
f"https://github.com/{c.PUBLIC_REPO}/releases/tag/v{state['latest_version']}"
|
|
1079
|
+
)
|
|
1080
|
+
state["check_status"] = "ok"
|
|
1081
|
+
except UpdateCheckRateLimited as e:
|
|
1082
|
+
state["check_status"] = "rate_limited"
|
|
1083
|
+
state["check_error"] = str(e)[:200]
|
|
1084
|
+
except (UpdateCheckNetworkError, UpdateCheckHTTPError) as e:
|
|
1085
|
+
state["check_status"] = "fetch_failed"
|
|
1086
|
+
state["check_error"] = str(e)[:200]
|
|
1087
|
+
except UpdateCheckParseError as e:
|
|
1088
|
+
state["check_status"] = "parse_failed"
|
|
1089
|
+
state["check_error"] = str(e)[:200]
|
|
1090
|
+
finally:
|
|
1091
|
+
c._save_update_state(state)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _spawn_background_update_check() -> None:
|
|
1095
|
+
"""Fire-and-forget the hidden `_update-check` worker.
|
|
1096
|
+
|
|
1097
|
+
Detached `subprocess.Popen` with `start_new_session=True` so a
|
|
1098
|
+
parent exit (the user closes the shell) doesn't propagate SIGHUP
|
|
1099
|
+
to the child. stdin/stdout/stderr are all `/dev/null` so the child
|
|
1100
|
+
can't accidentally pollute the parent's terminal. Exceptions are
|
|
1101
|
+
swallowed: a failed spawn must not break the parent command.
|
|
1102
|
+
"""
|
|
1103
|
+
try:
|
|
1104
|
+
subprocess.Popen(
|
|
1105
|
+
[sys.executable, os.path.realpath(sys.argv[0]), "_update-check"],
|
|
1106
|
+
stdin=subprocess.DEVNULL,
|
|
1107
|
+
stdout=subprocess.DEVNULL,
|
|
1108
|
+
stderr=subprocess.DEVNULL,
|
|
1109
|
+
start_new_session=True,
|
|
1110
|
+
close_fds=True,
|
|
1111
|
+
)
|
|
1112
|
+
except Exception:
|
|
1113
|
+
# Fire-and-forget: never let a spawn failure propagate.
|
|
1114
|
+
pass
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def cmd_update_check_internal(args) -> int:
|
|
1118
|
+
"""Hidden ``_update-check`` subcommand handler (spec §3.6).
|
|
1119
|
+
|
|
1120
|
+
The detached-refresh worker — not user-facing. Logs lifecycle
|
|
1121
|
+
events to ``update.log`` and rotates if needed. Always returns 0
|
|
1122
|
+
(any error is logged but the process exits cleanly so the parent
|
|
1123
|
+
spawn-and-forget contract holds).
|
|
1124
|
+
"""
|
|
1125
|
+
c = _cctally()
|
|
1126
|
+
# Ensure APP_DIR exists so log + state writes succeed on first run.
|
|
1127
|
+
c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1128
|
+
try:
|
|
1129
|
+
with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1130
|
+
_log_update_event(log_fd, "CHECK_START")
|
|
1131
|
+
c._do_update_check()
|
|
1132
|
+
_log_update_event(log_fd, "CHECK_EXIT", rc=0)
|
|
1133
|
+
except Exception as e:
|
|
1134
|
+
try:
|
|
1135
|
+
with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1136
|
+
_log_update_event(log_fd, "CHECK_EXIT", rc=1, error=str(e)[:200])
|
|
1137
|
+
except Exception:
|
|
1138
|
+
pass
|
|
1139
|
+
c._rotate_update_log_if_needed()
|
|
1140
|
+
return 0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
# === User-facing `cctally update` (spec §4) ===
|
|
1144
|
+
# `cmd_update` routes by mode flag. Mode flags are mutually exclusive
|
|
1145
|
+
# (argparse enforces it; the dispatcher's redundant check is defense in
|
|
1146
|
+
# depth for programmatic callers and a clearer error message). The
|
|
1147
|
+
# install path is staged across two tasks: Task 4 lands the validation
|
|
1148
|
+
# gates and the user-mode `--check` rendering, then raises
|
|
1149
|
+
# NotImplementedError for actual execution. Task 5 fills in execvp +
|
|
1150
|
+
# streaming.
|
|
1151
|
+
|
|
1152
|
+
# Sentinel for `--skip` with no positional argument — argparse `const`
|
|
1153
|
+
# doesn't accept `None` (collides with the absent-flag default). At
|
|
1154
|
+
# dispatch time the sentinel is replaced with `state.latest_version`.
|
|
1155
|
+
SKIP_USE_STATE_LATEST = "_USE_STATE_LATEST"
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _format_update_command(method: str, version: str | None) -> str:
|
|
1159
|
+
"""One-line shell recipe used by both --check renderers and the
|
|
1160
|
+
install-path manual fallback. Brew has no versioned formulae, so
|
|
1161
|
+
the version arg is ignored there (callers gate it earlier)."""
|
|
1162
|
+
if method == "brew":
|
|
1163
|
+
return "brew update --quiet && brew upgrade cctally"
|
|
1164
|
+
if method == "npm":
|
|
1165
|
+
v = version if version else "latest"
|
|
1166
|
+
return f"npm install -g cctally@{v}"
|
|
1167
|
+
return ""
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _prerelease_note(current: str) -> str | None:
|
|
1171
|
+
"""Spec §1.8 — prerelease users get a one-shot informational note in
|
|
1172
|
+
`--check` output. Returns the canned two-line message verbatim per
|
|
1173
|
+
spec when `current` looks like a prerelease (`X.Y.Z-id.N` form), else
|
|
1174
|
+
None. Wording is exact-string contract — tests pin it."""
|
|
1175
|
+
if "-" not in current:
|
|
1176
|
+
return None
|
|
1177
|
+
return (
|
|
1178
|
+
f"You're on prerelease {current}; this banner suggests stable.\n"
|
|
1179
|
+
"To track prereleases, manage manually: npm install -g cctally@next"
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _format_update_check_json(
|
|
1184
|
+
state: dict[str, Any], suppress: dict[str, Any]
|
|
1185
|
+
) -> dict[str, Any]:
|
|
1186
|
+
"""JSON shape for `cctally update --check --json` (spec §4.4)."""
|
|
1187
|
+
c = _cctally()
|
|
1188
|
+
cur = state.get("current_version")
|
|
1189
|
+
lat = state.get("latest_version")
|
|
1190
|
+
method = (state.get("install") or {}).get("method", "unknown")
|
|
1191
|
+
skipped = lat in suppress.get("skipped_versions", []) if lat else False
|
|
1192
|
+
in_remind_window = False
|
|
1193
|
+
remind = suppress.get("remind_after")
|
|
1194
|
+
if remind is not None and lat is not None:
|
|
1195
|
+
try:
|
|
1196
|
+
if not c._semver_gt(lat, remind["version"]):
|
|
1197
|
+
until = dt.datetime.fromisoformat(remind["until_utc"])
|
|
1198
|
+
if _now_utc() < until:
|
|
1199
|
+
in_remind_window = True
|
|
1200
|
+
except (KeyError, ValueError):
|
|
1201
|
+
pass
|
|
1202
|
+
available = False
|
|
1203
|
+
if cur and lat:
|
|
1204
|
+
try:
|
|
1205
|
+
available = (
|
|
1206
|
+
c._semver_gt(lat, cur)
|
|
1207
|
+
and not skipped
|
|
1208
|
+
and not in_remind_window
|
|
1209
|
+
)
|
|
1210
|
+
except ValueError:
|
|
1211
|
+
available = False
|
|
1212
|
+
return {
|
|
1213
|
+
"_schema": 1,
|
|
1214
|
+
"current_version": cur,
|
|
1215
|
+
"latest_version": lat,
|
|
1216
|
+
"available": available,
|
|
1217
|
+
"method": method,
|
|
1218
|
+
"update_command": c._format_update_command(method, None),
|
|
1219
|
+
"release_notes_url": state.get("latest_version_url"),
|
|
1220
|
+
"check_status": state.get("check_status"),
|
|
1221
|
+
"check_error": state.get("check_error"),
|
|
1222
|
+
"checked_at_utc": state.get("checked_at_utc"),
|
|
1223
|
+
"suppress": {
|
|
1224
|
+
"skipped": skipped,
|
|
1225
|
+
"remind_after_utc": (
|
|
1226
|
+
remind.get("until_utc") if isinstance(remind, dict) else None
|
|
1227
|
+
),
|
|
1228
|
+
},
|
|
1229
|
+
"prerelease_note": c._prerelease_note(cur) if cur else None,
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
_UPDATE_METHOD_HUMAN_LABEL = {
|
|
1234
|
+
"brew": "Homebrew",
|
|
1235
|
+
"npm": "npm",
|
|
1236
|
+
"unknown": "unknown",
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _format_update_check_human(
|
|
1241
|
+
state: dict[str, Any], suppress: dict[str, Any]
|
|
1242
|
+
) -> str:
|
|
1243
|
+
"""Multi-line plaintext block for `cctally update --check` (spec §4.4).
|
|
1244
|
+
|
|
1245
|
+
Two-space-column table layout: every label left-padded to width 10
|
|
1246
|
+
(`Will run` is the longest at 8 chars + 2-space gutter). Method row
|
|
1247
|
+
appends ` (auto-detected)` per spec example. Up-to-date / unknown
|
|
1248
|
+
variants append a fallback line below the table.
|
|
1249
|
+
"""
|
|
1250
|
+
c = _cctally()
|
|
1251
|
+
cur = state.get("current_version") or "unknown"
|
|
1252
|
+
lat = state.get("latest_version") or "unknown"
|
|
1253
|
+
method = (state.get("install") or {}).get("method", "unknown")
|
|
1254
|
+
url = state.get("latest_version_url")
|
|
1255
|
+
status = state.get("check_status")
|
|
1256
|
+
err = state.get("check_error")
|
|
1257
|
+
cooked_available = False
|
|
1258
|
+
if state.get("current_version") and state.get("latest_version"):
|
|
1259
|
+
try:
|
|
1260
|
+
cooked_available = c._semver_gt(lat, cur) and \
|
|
1261
|
+
lat not in suppress.get("skipped_versions", [])
|
|
1262
|
+
except ValueError:
|
|
1263
|
+
cooked_available = False
|
|
1264
|
+
|
|
1265
|
+
method_label = _UPDATE_METHOD_HUMAN_LABEL.get(method, method)
|
|
1266
|
+
lines = [
|
|
1267
|
+
f"{'Current':<10}{cur}",
|
|
1268
|
+
f"{'Latest':<10}{lat}",
|
|
1269
|
+
f"{'Method':<10}{method_label} (auto-detected)",
|
|
1270
|
+
]
|
|
1271
|
+
will_run = c._format_update_command(method, None)
|
|
1272
|
+
if will_run:
|
|
1273
|
+
lines.append(f"{'Will run':<10}{will_run}")
|
|
1274
|
+
if url:
|
|
1275
|
+
lines.append(f"{'Notes':<10}{url}")
|
|
1276
|
+
if status and status != "ok":
|
|
1277
|
+
status_value = status + (f" ({err})" if err else "")
|
|
1278
|
+
lines.append(f"{'Status':<10}{status_value}")
|
|
1279
|
+
lines.append("")
|
|
1280
|
+
if method == "unknown":
|
|
1281
|
+
# No remote check is possible for source / dev installs; render
|
|
1282
|
+
# the manual fallback rather than the "you're up to date" lie.
|
|
1283
|
+
lines.append(
|
|
1284
|
+
"Automatic update unavailable for this install. Visit "
|
|
1285
|
+
f"{url or 'https://github.com/' + c.PUBLIC_REPO + '/releases'} "
|
|
1286
|
+
"to install manually."
|
|
1287
|
+
)
|
|
1288
|
+
elif cooked_available:
|
|
1289
|
+
lines.append("Run `cctally update` to install.")
|
|
1290
|
+
else:
|
|
1291
|
+
lines.append("You're up to date.")
|
|
1292
|
+
note = c._prerelease_note(cur)
|
|
1293
|
+
if note:
|
|
1294
|
+
lines.append("")
|
|
1295
|
+
lines.append(note)
|
|
1296
|
+
return "\n".join(lines)
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _do_update_skip(version_arg: str) -> int:
|
|
1300
|
+
"""`cctally update --skip [VERSION]` — record a skipped version."""
|
|
1301
|
+
c = _cctally()
|
|
1302
|
+
if version_arg == SKIP_USE_STATE_LATEST:
|
|
1303
|
+
state = c._load_update_state()
|
|
1304
|
+
if state is None or not state.get("latest_version"):
|
|
1305
|
+
print(
|
|
1306
|
+
"cctally update: no version in cache to skip; run "
|
|
1307
|
+
"`cctally update --check` first",
|
|
1308
|
+
file=sys.stderr,
|
|
1309
|
+
)
|
|
1310
|
+
return 1
|
|
1311
|
+
version = state["latest_version"]
|
|
1312
|
+
else:
|
|
1313
|
+
if not c._SEMVER_RE.match(version_arg):
|
|
1314
|
+
print(
|
|
1315
|
+
f"cctally update: invalid version {version_arg!r} "
|
|
1316
|
+
"(expected X.Y.Z[-id.N])",
|
|
1317
|
+
file=sys.stderr,
|
|
1318
|
+
)
|
|
1319
|
+
return 2
|
|
1320
|
+
version = version_arg
|
|
1321
|
+
suppress = c._load_update_suppress()
|
|
1322
|
+
skipped = list(suppress.get("skipped_versions", []))
|
|
1323
|
+
if version not in skipped:
|
|
1324
|
+
skipped.append(version)
|
|
1325
|
+
suppress["skipped_versions"] = skipped
|
|
1326
|
+
suppress.setdefault("_schema", 1)
|
|
1327
|
+
c._save_update_suppress(suppress)
|
|
1328
|
+
print(
|
|
1329
|
+
f"Skipped cctally {version}. You won't be reminded about this version."
|
|
1330
|
+
)
|
|
1331
|
+
return 0
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _do_update_remind_later(days: int) -> int:
|
|
1335
|
+
"""`cctally update --remind-later [DAYS]` — defer banner for N days."""
|
|
1336
|
+
c = _cctally()
|
|
1337
|
+
if not (1 <= days <= 365):
|
|
1338
|
+
print(
|
|
1339
|
+
f"cctally update: --remind-later must be 1..365 (got {days})",
|
|
1340
|
+
file=sys.stderr,
|
|
1341
|
+
)
|
|
1342
|
+
return 2
|
|
1343
|
+
state = c._load_update_state()
|
|
1344
|
+
if state is None or not state.get("latest_version"):
|
|
1345
|
+
print(
|
|
1346
|
+
"cctally update: no version in cache to defer; run "
|
|
1347
|
+
"`cctally update --check` first",
|
|
1348
|
+
file=sys.stderr,
|
|
1349
|
+
)
|
|
1350
|
+
return 1
|
|
1351
|
+
until = (_now_utc() + dt.timedelta(days=days)).isoformat()
|
|
1352
|
+
suppress = c._load_update_suppress()
|
|
1353
|
+
suppress["remind_after"] = {
|
|
1354
|
+
"version": state["latest_version"],
|
|
1355
|
+
"until_utc": until,
|
|
1356
|
+
}
|
|
1357
|
+
suppress.setdefault("_schema", 1)
|
|
1358
|
+
c._save_update_suppress(suppress)
|
|
1359
|
+
print(
|
|
1360
|
+
f"Will remind in {days} day{'s' if days != 1 else ''} "
|
|
1361
|
+
"(or sooner if a newer version drops)."
|
|
1362
|
+
)
|
|
1363
|
+
return 0
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
_UPDATE_CHECK_JSON_UNAVAILABLE_ENVELOPE = {
|
|
1367
|
+
"_schema": 1,
|
|
1368
|
+
"current_version": None,
|
|
1369
|
+
"latest_version": None,
|
|
1370
|
+
"available": False,
|
|
1371
|
+
"method": "unknown",
|
|
1372
|
+
"update_command": None,
|
|
1373
|
+
"release_notes_url": None,
|
|
1374
|
+
"check_status": "unavailable",
|
|
1375
|
+
"check_error": "state unavailable",
|
|
1376
|
+
"checked_at_utc": None,
|
|
1377
|
+
"suppress": {"skipped": False, "remind_after_utc": None},
|
|
1378
|
+
"prerelease_note": None,
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _do_update_check_user(*, force: bool, output_json: bool) -> int:
|
|
1383
|
+
"""`cctally update --check` — user-facing version-check render.
|
|
1384
|
+
|
|
1385
|
+
`_do_update_check()` translates known failure modes (network,
|
|
1386
|
+
parse, rate-limit) into `check_status` fields on the state file
|
|
1387
|
+
via its own internal try/except (Task 3, spec §3.5); any unexpected
|
|
1388
|
+
exception that escapes is a real bug and is left to surface in the
|
|
1389
|
+
outer error log. The post-command banner hook in `main()` already
|
|
1390
|
+
isolates banner failures.
|
|
1391
|
+
|
|
1392
|
+
Refresh gate matches the user-facing docs (`docs/commands/update.md`):
|
|
1393
|
+
`--check` refreshes when TTL has elapsed; `--force` bypasses the TTL
|
|
1394
|
+
gate to refresh even on a fresh cache. Synchronous refresh here also
|
|
1395
|
+
pre-empts the post-command background spawn — `_do_update_check`
|
|
1396
|
+
touches `update-check.last-fetch` first, so `_is_update_check_due`
|
|
1397
|
+
returns False by the time the hook runs.
|
|
1398
|
+
"""
|
|
1399
|
+
c = _cctally()
|
|
1400
|
+
config = load_config()
|
|
1401
|
+
if force or c._is_update_check_due(config):
|
|
1402
|
+
c._do_update_check()
|
|
1403
|
+
state = c._load_update_state()
|
|
1404
|
+
if state is None:
|
|
1405
|
+
# Still nothing on disk — try once even with a fresh TTL marker
|
|
1406
|
+
# so the first invocation isn't a content-free "state unavailable".
|
|
1407
|
+
c._do_update_check()
|
|
1408
|
+
state = c._load_update_state()
|
|
1409
|
+
if state is None:
|
|
1410
|
+
if output_json:
|
|
1411
|
+
# Emit a parseable minimal envelope so JSON consumers always
|
|
1412
|
+
# get a payload; rc stays 0 (best-effort, matches the
|
|
1413
|
+
# `cmd_refresh_usage` precedent that network failures are
|
|
1414
|
+
# not user-actionable errors). Spec §4.4.
|
|
1415
|
+
print(
|
|
1416
|
+
json.dumps(_UPDATE_CHECK_JSON_UNAVAILABLE_ENVELOPE, indent=2)
|
|
1417
|
+
)
|
|
1418
|
+
return 0
|
|
1419
|
+
print("cctally update: state unavailable", file=sys.stderr)
|
|
1420
|
+
return 0
|
|
1421
|
+
suppress = c._load_update_suppress()
|
|
1422
|
+
if output_json:
|
|
1423
|
+
print(json.dumps(c._format_update_check_json(state, suppress), indent=2))
|
|
1424
|
+
else:
|
|
1425
|
+
print(c._format_update_check_human(state, suppress))
|
|
1426
|
+
return 0
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _preflight_install(method: InstallMethod, version: str | None) -> None:
|
|
1430
|
+
"""Validate the install plan before any subprocess runs (spec §5.1).
|
|
1431
|
+
|
|
1432
|
+
Ordered checks (each raises and short-circuits the rest):
|
|
1433
|
+
1. method != "unknown" — manual-fallback bucket per §2.4.
|
|
1434
|
+
2. version (if not None) matches `_SEMVER_RE` — `X.Y.Z` or
|
|
1435
|
+
`X.Y.Z-prerelease`.
|
|
1436
|
+
3. (method, version) compatibility — brew has no versioned
|
|
1437
|
+
formulae; pinned-version installs must be done manually.
|
|
1438
|
+
4. npm-only: the `<prefix>/bin` directory must be writable; if
|
|
1439
|
+
not, surface the sudo / `npm config set prefix` recipes
|
|
1440
|
+
instead of letting npm fail with EACCES inside the run.
|
|
1441
|
+
|
|
1442
|
+
Raises :class:`UpdateValidationError` for input-validation failures
|
|
1443
|
+
(rc=2 at the boundary): invalid --version syntax, --version+brew
|
|
1444
|
+
combo. Raises :class:`UpdateError` for environment / runtime
|
|
1445
|
+
failures (rc=1 at the boundary): unknown install method, npm
|
|
1446
|
+
prefix not writable.
|
|
1447
|
+
|
|
1448
|
+
Brew preflight is intentionally a no-op beyond the version-combo
|
|
1449
|
+
check (codex review #2): homebrew installs into ``libexec/bin/``,
|
|
1450
|
+
so ``realpath`` lands inside the keg, not the brew bin prefix; brew
|
|
1451
|
+
has its own permission model and ``brew doctor`` is the diagnostic
|
|
1452
|
+
users already know.
|
|
1453
|
+
"""
|
|
1454
|
+
c = _cctally()
|
|
1455
|
+
if method.method == "unknown":
|
|
1456
|
+
raise UpdateError(
|
|
1457
|
+
"Install method is 'unknown' — automatic update unavailable.\n"
|
|
1458
|
+
"If you installed from source: cd <your cctally repo> && git pull && bin/symlink"
|
|
1459
|
+
)
|
|
1460
|
+
if version is not None and not c._SEMVER_RE.match(version):
|
|
1461
|
+
raise UpdateValidationError(
|
|
1462
|
+
f"Invalid version: {version!r} (expected X.Y.Z or X.Y.Z-id.N)"
|
|
1463
|
+
)
|
|
1464
|
+
if method.method == "brew" and version is not None:
|
|
1465
|
+
raise UpdateValidationError(
|
|
1466
|
+
"Pinned-version install is not supported on Homebrew "
|
|
1467
|
+
"(no versioned formulae).\n"
|
|
1468
|
+
"To install a specific version manually:\n"
|
|
1469
|
+
f" brew uninstall cctally\n"
|
|
1470
|
+
f" brew install https://github.com/{c.PUBLIC_REPO}/releases/download/v{version}/cctally-{version}.tar.gz"
|
|
1471
|
+
)
|
|
1472
|
+
if method.method == "npm":
|
|
1473
|
+
prefix_bin = os.path.join(method.npm_prefix, "bin")
|
|
1474
|
+
if not os.access(prefix_bin, os.W_OK):
|
|
1475
|
+
raise UpdateError(
|
|
1476
|
+
f"npm prefix '{prefix_bin}' is not writable.\n"
|
|
1477
|
+
f"Run with sudo: sudo npm install -g cctally@{version or 'latest'}\n"
|
|
1478
|
+
"Or relocate: npm config set prefix ~/.npm-global"
|
|
1479
|
+
)
|
|
1480
|
+
# brew: NO preflight beyond the --version combo check above
|
|
1481
|
+
# (codex review #2 amendment to spec §5.1).
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _build_update_steps(
|
|
1485
|
+
method: InstallMethod, version: str | None
|
|
1486
|
+
) -> list[tuple[str, list[str]]]:
|
|
1487
|
+
"""Build the ordered list of subprocess steps for an install plan.
|
|
1488
|
+
|
|
1489
|
+
Each step is ``(human_name, argv)`` where ``human_name`` is the
|
|
1490
|
+
label rendered in dry-run output and the dashboard live-stream
|
|
1491
|
+
modal, and ``argv`` is the list passed to ``subprocess.Popen`` (no
|
|
1492
|
+
shell). Brew is two steps (``brew update`` then ``brew upgrade
|
|
1493
|
+
cctally``) per spec §5.2 + Q6a — splitting them gives diagnostic
|
|
1494
|
+
clarity (a stale tap manifesting as a hung ``brew update`` is
|
|
1495
|
+
distinguishable from an ``upgrade`` failure). npm is one step.
|
|
1496
|
+
"""
|
|
1497
|
+
if method.method == "brew":
|
|
1498
|
+
return [
|
|
1499
|
+
("brew update", ["brew", "update", "--quiet"]),
|
|
1500
|
+
("brew upgrade cctally", ["brew", "upgrade", "cctally"]),
|
|
1501
|
+
]
|
|
1502
|
+
if method.method == "npm":
|
|
1503
|
+
target = f"cctally@{version}" if version else "cctally@latest"
|
|
1504
|
+
return [("npm install -g", ["npm", "install", "-g", target])]
|
|
1505
|
+
raise AssertionError(
|
|
1506
|
+
f"step builder called with method={method.method!r} "
|
|
1507
|
+
"(should have been rejected by _preflight_install)"
|
|
1508
|
+
)
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _run_streaming(
|
|
1512
|
+
cmd: list[str],
|
|
1513
|
+
*,
|
|
1514
|
+
on_stdout: Callable[[str], None],
|
|
1515
|
+
on_stderr: Callable[[str], None],
|
|
1516
|
+
log_fd,
|
|
1517
|
+
) -> int:
|
|
1518
|
+
"""Run ``cmd``, line-buffer stdout/stderr to callbacks, append to log.
|
|
1519
|
+
|
|
1520
|
+
Two-thread pump (one per stream) → callbacks + log lines. Each log
|
|
1521
|
+
line is ``<iso-utc> <STREAM> <raw-line>`` so a grep for the stream
|
|
1522
|
+
label still recovers the chronological order even when stdout and
|
|
1523
|
+
stderr interleave at sub-line resolution. ``proc.wait()`` is the
|
|
1524
|
+
synchronization point; the pump threads are daemons so a crash in
|
|
1525
|
+
the parent doesn't leave them lingering.
|
|
1526
|
+
|
|
1527
|
+
Used by:
|
|
1528
|
+
- the CLI install path (Task 5; this file) where ``on_stdout`` /
|
|
1529
|
+
``on_stderr`` print to the parent's stdout/stderr;
|
|
1530
|
+
- the dashboard ``UpdateWorker`` thread (Task 6) where the
|
|
1531
|
+
callbacks push lines into a per-stream ring buffer for SSE.
|
|
1532
|
+
|
|
1533
|
+
Stdin is inherited from the parent — the wrapped commands
|
|
1534
|
+
(``brew update``, ``npm install -g``) take no input; piping
|
|
1535
|
+
``DEVNULL`` would just add a syscall.
|
|
1536
|
+
"""
|
|
1537
|
+
proc = subprocess.Popen(
|
|
1538
|
+
cmd,
|
|
1539
|
+
stdout=subprocess.PIPE,
|
|
1540
|
+
stderr=subprocess.PIPE,
|
|
1541
|
+
bufsize=1,
|
|
1542
|
+
text=True,
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
def pump(stream, cb, label):
|
|
1546
|
+
for line in stream:
|
|
1547
|
+
cb(line.rstrip("\n"))
|
|
1548
|
+
if log_fd is not None:
|
|
1549
|
+
log_fd.write(f"{_now_utc().isoformat()} {label} {line}")
|
|
1550
|
+
log_fd.flush()
|
|
1551
|
+
stream.close()
|
|
1552
|
+
|
|
1553
|
+
t_out = threading.Thread(
|
|
1554
|
+
target=pump, args=(proc.stdout, on_stdout, "STDOUT"), daemon=True
|
|
1555
|
+
)
|
|
1556
|
+
t_err = threading.Thread(
|
|
1557
|
+
target=pump, args=(proc.stderr, on_stderr, "STDERR"), daemon=True
|
|
1558
|
+
)
|
|
1559
|
+
t_out.start()
|
|
1560
|
+
t_err.start()
|
|
1561
|
+
proc.wait()
|
|
1562
|
+
t_out.join()
|
|
1563
|
+
t_err.join()
|
|
1564
|
+
return proc.returncode
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def _do_update_install(
|
|
1568
|
+
*, version: str | None, dry_run: bool, output_json: bool
|
|
1569
|
+
) -> int:
|
|
1570
|
+
"""`cctally update` (no mode flag) — install execution (spec §5).
|
|
1571
|
+
|
|
1572
|
+
Task-4 inline gates moved into :func:`_preflight_install`. Real
|
|
1573
|
+
install: acquire lock → log INSTALL_START → run each step (logging
|
|
1574
|
+
STEP_START/STEP_EXIT), bail on the first non-zero rc → log
|
|
1575
|
+
INSTALL_SUCCESS → release lock + rotate log in finally.
|
|
1576
|
+
|
|
1577
|
+
Dry-run path passes ``mutate=False`` to detection (codex review
|
|
1578
|
+
fix #4), prints "Would run: ..." (or one JSON-line per step) for
|
|
1579
|
+
each planned step, and exits 0 without touching the lock or
|
|
1580
|
+
running any subprocesses.
|
|
1581
|
+
|
|
1582
|
+
Raises :class:`UpdateError` (rc=1 at boundary) for unknown method
|
|
1583
|
+
/ write-perm-denied; :class:`UpdateValidationError` (rc=2) for
|
|
1584
|
+
invalid --version / --version+brew. The boundary distinction is
|
|
1585
|
+
enforced by :func:`cmd_update`'s try/except below.
|
|
1586
|
+
"""
|
|
1587
|
+
c = _cctally()
|
|
1588
|
+
method = c._detect_install_method(mutate=not dry_run)
|
|
1589
|
+
c._preflight_install(method, version)
|
|
1590
|
+
steps = c._build_update_steps(method, version)
|
|
1591
|
+
if dry_run:
|
|
1592
|
+
for name, cmd in steps:
|
|
1593
|
+
if output_json:
|
|
1594
|
+
print(json.dumps({"step": name, "would_run": cmd}))
|
|
1595
|
+
else:
|
|
1596
|
+
quoted = " ".join(shlex.quote(c2) for c2 in cmd)
|
|
1597
|
+
print(f"Would run: {quoted}")
|
|
1598
|
+
return 0
|
|
1599
|
+
c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1600
|
+
lock_fd = c._acquire_update_lock()
|
|
1601
|
+
try:
|
|
1602
|
+
with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1603
|
+
_log_update_event(log_fd, "INSTALL_START", method=method.method)
|
|
1604
|
+
for step_name, cmd in steps:
|
|
1605
|
+
_log_update_event(log_fd, "STEP_START", name=step_name)
|
|
1606
|
+
rc = c._run_streaming(
|
|
1607
|
+
cmd,
|
|
1608
|
+
on_stdout=lambda line: print(line, file=sys.stdout, flush=True),
|
|
1609
|
+
on_stderr=lambda line: print(line, file=sys.stderr, flush=True),
|
|
1610
|
+
log_fd=log_fd,
|
|
1611
|
+
)
|
|
1612
|
+
_log_update_event(log_fd, "STEP_EXIT", name=step_name, rc=rc)
|
|
1613
|
+
if rc != 0:
|
|
1614
|
+
return 1
|
|
1615
|
+
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
1616
|
+
c._stamp_install_success_to_state(version, method)
|
|
1617
|
+
return 0
|
|
1618
|
+
finally:
|
|
1619
|
+
c._release_update_lock(lock_fd)
|
|
1620
|
+
c._rotate_update_log_if_needed()
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
# === Dashboard execvp re-entry (spec §5.7) ===
|
|
1624
|
+
# ORIGINAL_SYS_ARGV / ORIGINAL_ENTRYPOINT are captured at dashboard
|
|
1625
|
+
# server boot in cmd_dashboard (in bin/cctally, written via
|
|
1626
|
+
# ``global ORIGINAL_SYS_ARGV, ORIGINAL_ENTRYPOINT`` so the running
|
|
1627
|
+
# binary's view of argv survives the in-place execvp). They stay
|
|
1628
|
+
# defined in bin/cctally so cmd_dashboard's write site is unchanged
|
|
1629
|
+
# and tests that ``monkeypatch.setitem(ns, "ORIGINAL_SYS_ARGV", …)``
|
|
1630
|
+
# propagate to this read site.
|
|
1631
|
+
#
|
|
1632
|
+
# _resolve_execvp_target uses them to return (entrypoint, exec_argv)
|
|
1633
|
+
# for os.execvp:
|
|
1634
|
+
# - npm: entrypoint = <prefix>/bin/cctally → Node shim, which
|
|
1635
|
+
# re-resolves CCTALLY_PYTHON before re-spawning Python (so a
|
|
1636
|
+
# custom interpreter setting survives the restart).
|
|
1637
|
+
# - brew: entrypoint = <brew>/bin/cctally → symlink into the
|
|
1638
|
+
# post-upgrade Python script with its rewritten shebang.
|
|
1639
|
+
# - Fallback when shutil.which("cctally") returned None: use
|
|
1640
|
+
# sys.argv[0] directly. Loses the npm shim layer; we accept the
|
|
1641
|
+
# degraded edge case rather than guess.
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _resolve_execvp_target() -> tuple[str, list[str]]:
|
|
1645
|
+
"""Return (entrypoint, exec_argv) per spec §5.7.
|
|
1646
|
+
|
|
1647
|
+
Re-enters the npm shim by execvp'ing the PATH-resolved ``cctally``
|
|
1648
|
+
(Node shim for npm, brew symlink for brew). Falls back to
|
|
1649
|
+
``sys.argv[0]`` only when ``shutil.which`` returned ``None`` at
|
|
1650
|
+
dashboard boot (rare absolute-path invocation).
|
|
1651
|
+
"""
|
|
1652
|
+
c = _cctally()
|
|
1653
|
+
if c.ORIGINAL_ENTRYPOINT is not None:
|
|
1654
|
+
return (
|
|
1655
|
+
c.ORIGINAL_ENTRYPOINT,
|
|
1656
|
+
[c.ORIGINAL_ENTRYPOINT, *c.ORIGINAL_SYS_ARGV[1:]],
|
|
1657
|
+
)
|
|
1658
|
+
return (c.ORIGINAL_SYS_ARGV[0], list(c.ORIGINAL_SYS_ARGV))
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
class UpdateWorker:
|
|
1662
|
+
"""Single-slot dashboard-side update orchestrator (spec §5.6).
|
|
1663
|
+
|
|
1664
|
+
A single instance lives on the dashboard server (created in
|
|
1665
|
+
cmd_dashboard, exposed as the module-level ``_UPDATE_WORKER``).
|
|
1666
|
+
``start()`` returns ``(True, run_id)`` on accept and
|
|
1667
|
+
``(False, current_run_id)`` when a run is already in progress —
|
|
1668
|
+
serializes concurrent button clicks without taking the install lock
|
|
1669
|
+
on the rejected path. ``_run`` runs preflight → lock → streamed
|
|
1670
|
+
steps → execvp on success / error_event on failure / done on
|
|
1671
|
+
non-zero subprocess exit. The ``released`` flag enforces the
|
|
1672
|
+
idempotent-release contract from spec §5.6.1: success path releases
|
|
1673
|
+
pre-execvp and skips the finally release; pre-execvp failure path
|
|
1674
|
+
releases in finally.
|
|
1675
|
+
"""
|
|
1676
|
+
|
|
1677
|
+
def __init__(self) -> None:
|
|
1678
|
+
self._lock = threading.Lock()
|
|
1679
|
+
self._current_id: "str | None" = None
|
|
1680
|
+
# run_id -> queue.Queue of event dicts. Each subscriber drains
|
|
1681
|
+
# via ``stream(run_id)``; the worker thread enqueues via
|
|
1682
|
+
# ``_emit``. A single subscriber per run is the dashboard
|
|
1683
|
+
# contract; multi-subscriber broadcast is out of scope. The
|
|
1684
|
+
# producer (``_run``) intentionally does NOT pop its entry —
|
|
1685
|
+
# that would race a late consumer (#32): the worker thread can
|
|
1686
|
+
# complete its finally before the consumer enters ``stream()``,
|
|
1687
|
+
# leaving the consumer to look up a missing key. Cleanup
|
|
1688
|
+
# ownership now belongs to ``stream()``'s finally; if no
|
|
1689
|
+
# consumer ever subscribes, ``start()`` reaps stale entries on
|
|
1690
|
+
# the next run.
|
|
1691
|
+
self._streams: dict[str, "queue.Queue"] = {}
|
|
1692
|
+
|
|
1693
|
+
def start(self, version: "str | None") -> tuple[bool, str]:
|
|
1694
|
+
"""Begin a run. Returns (accepted, run_id).
|
|
1695
|
+
|
|
1696
|
+
``accepted=False`` when another run is in progress; the
|
|
1697
|
+
returned ``run_id`` is the in-progress one (so the caller can
|
|
1698
|
+
surface it as ``run_id_in_progress`` to the client).
|
|
1699
|
+
"""
|
|
1700
|
+
with self._lock:
|
|
1701
|
+
if self._current_id is not None:
|
|
1702
|
+
return (False, self._current_id)
|
|
1703
|
+
# Reap any stale entries from prior no-consumer runs. Safe
|
|
1704
|
+
# under the lock: ``_current_id is None`` here, so no live
|
|
1705
|
+
# stream() generator holds a reference into the dict by
|
|
1706
|
+
# run_id (only by local-variable q ref, which survives the
|
|
1707
|
+
# pop).
|
|
1708
|
+
self._streams.clear()
|
|
1709
|
+
run_id = secrets.token_hex(8)
|
|
1710
|
+
self._current_id = run_id
|
|
1711
|
+
self._streams[run_id] = queue.Queue()
|
|
1712
|
+
threading.Thread(
|
|
1713
|
+
target=self._run, args=(run_id, version), daemon=True,
|
|
1714
|
+
name="cctally-update-worker",
|
|
1715
|
+
).start()
|
|
1716
|
+
return (True, run_id)
|
|
1717
|
+
|
|
1718
|
+
def status(self) -> dict:
|
|
1719
|
+
"""Return ``{"current_run_id": <run_id|None>}`` for /api/update/status."""
|
|
1720
|
+
with self._lock:
|
|
1721
|
+
return {"current_run_id": self._current_id}
|
|
1722
|
+
|
|
1723
|
+
def _emit(self, run_id: str, event: dict) -> None:
|
|
1724
|
+
q = self._streams.get(run_id)
|
|
1725
|
+
if q is not None:
|
|
1726
|
+
q.put(event)
|
|
1727
|
+
|
|
1728
|
+
def stream(self, run_id: str):
|
|
1729
|
+
"""Generator yielding events for the given run_id.
|
|
1730
|
+
|
|
1731
|
+
Yields a ``{"type": "heartbeat"}`` event every 15 s of idle so
|
|
1732
|
+
the SSE proxy / EventSource keep-alive stays warm. Closes
|
|
1733
|
+
(returns) on the terminal events: ``execvp`` (success path),
|
|
1734
|
+
``error_event`` (preflight or other UpdateError), ``done``
|
|
1735
|
+
(non-zero subprocess exit). Yields nothing and returns
|
|
1736
|
+
immediately for unknown run_ids — the HTTP handler then closes
|
|
1737
|
+
the SSE connection.
|
|
1738
|
+
"""
|
|
1739
|
+
q = self._streams.get(run_id)
|
|
1740
|
+
if q is None:
|
|
1741
|
+
return
|
|
1742
|
+
try:
|
|
1743
|
+
while True:
|
|
1744
|
+
try:
|
|
1745
|
+
ev = q.get(timeout=15)
|
|
1746
|
+
except queue.Empty:
|
|
1747
|
+
yield {"type": "heartbeat"}
|
|
1748
|
+
continue
|
|
1749
|
+
yield ev
|
|
1750
|
+
if ev["type"] in ("execvp", "error_event", "done"):
|
|
1751
|
+
return
|
|
1752
|
+
finally:
|
|
1753
|
+
# Only reap when the worker is no longer the active producer
|
|
1754
|
+
# for this run_id. A mid-run modal close unwinds this
|
|
1755
|
+
# generator while ``_current_id == run_id`` and ``_run`` is
|
|
1756
|
+
# still emitting — popping here would silently drop those
|
|
1757
|
+
# events, and a modal reopen (slice.runId is preserved per
|
|
1758
|
+
# spec §6) would re-subscribe against a missing queue.
|
|
1759
|
+
# Cleanup still happens on the first ``stream()`` exit AFTER
|
|
1760
|
+
# the worker terminates (its finally clears _current_id), or
|
|
1761
|
+
# via ``start()``'s reap on the next run.
|
|
1762
|
+
with self._lock:
|
|
1763
|
+
if self._current_id != run_id:
|
|
1764
|
+
self._streams.pop(run_id, None)
|
|
1765
|
+
|
|
1766
|
+
def _run(self, run_id: str, version: "str | None") -> None:
|
|
1767
|
+
c = _cctally()
|
|
1768
|
+
lock_fd = None
|
|
1769
|
+
released = False # idempotent-release guard per §5.6.1
|
|
1770
|
+
log_fd = None
|
|
1771
|
+
try:
|
|
1772
|
+
method = c._detect_install_method(mutate=True)
|
|
1773
|
+
c._preflight_install(method, version)
|
|
1774
|
+
c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1775
|
+
lock_fd = c._acquire_update_lock()
|
|
1776
|
+
log_fd = open(c.UPDATE_LOG_PATH, "a", encoding="utf-8")
|
|
1777
|
+
_log_update_event(log_fd, "INSTALL_START", method=method.method)
|
|
1778
|
+
for step_name, cmd in c._build_update_steps(method, version):
|
|
1779
|
+
self._emit(run_id, {"type": "step", "name": step_name})
|
|
1780
|
+
_log_update_event(log_fd, "STEP_START", name=step_name)
|
|
1781
|
+
rc = c._run_streaming(
|
|
1782
|
+
cmd,
|
|
1783
|
+
on_stdout=lambda line, rid=run_id: self._emit(
|
|
1784
|
+
rid, {"type": "stdout", "data": line}
|
|
1785
|
+
),
|
|
1786
|
+
on_stderr=lambda line, rid=run_id: self._emit(
|
|
1787
|
+
rid, {"type": "stderr", "data": line}
|
|
1788
|
+
),
|
|
1789
|
+
log_fd=log_fd,
|
|
1790
|
+
)
|
|
1791
|
+
_log_update_event(log_fd, "STEP_EXIT", name=step_name, rc=rc)
|
|
1792
|
+
self._emit(run_id, {"type": "exit", "rc": rc, "step": step_name})
|
|
1793
|
+
if rc != 0:
|
|
1794
|
+
self._emit(run_id, {"type": "done", "success": False})
|
|
1795
|
+
return
|
|
1796
|
+
_log_update_event(log_fd, "INSTALL_SUCCESS")
|
|
1797
|
+
c._stamp_install_success_to_state(version, method)
|
|
1798
|
+
entrypoint, exec_argv = c._resolve_execvp_target()
|
|
1799
|
+
self._emit(run_id, {"type": "execvp", "argv": exec_argv})
|
|
1800
|
+
try:
|
|
1801
|
+
log_fd.close()
|
|
1802
|
+
finally:
|
|
1803
|
+
log_fd = None
|
|
1804
|
+
# 0.5 s breathing room so the SSE pump flushes the final
|
|
1805
|
+
# ``execvp`` event to the browser before we hand the
|
|
1806
|
+
# process over to the new image. If the browser misses it
|
|
1807
|
+
# the polling fallback (/api/update/status) covers reentry.
|
|
1808
|
+
time.sleep(0.5)
|
|
1809
|
+
c._release_update_lock(lock_fd)
|
|
1810
|
+
released = True
|
|
1811
|
+
os.execvp(entrypoint, exec_argv)
|
|
1812
|
+
except UpdateError as e:
|
|
1813
|
+
self._emit(run_id, {"type": "error_event", "message": str(e)})
|
|
1814
|
+
except Exception as e:
|
|
1815
|
+
self._emit(
|
|
1816
|
+
run_id, {"type": "error_event", "message": f"unexpected: {e!r}"}
|
|
1817
|
+
)
|
|
1818
|
+
finally:
|
|
1819
|
+
if log_fd is not None:
|
|
1820
|
+
try:
|
|
1821
|
+
log_fd.close()
|
|
1822
|
+
except Exception:
|
|
1823
|
+
pass
|
|
1824
|
+
if lock_fd is not None and not released:
|
|
1825
|
+
try:
|
|
1826
|
+
c._release_update_lock(lock_fd)
|
|
1827
|
+
except Exception:
|
|
1828
|
+
pass
|
|
1829
|
+
with self._lock:
|
|
1830
|
+
self._current_id = None
|
|
1831
|
+
# _streams[run_id] intentionally retained — see class
|
|
1832
|
+
# docstring. Cleanup is owned by stream()'s finally;
|
|
1833
|
+
# start() sweeps stale entries on the next run.
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
class _DashboardUpdateCheckThread(threading.Thread):
|
|
1837
|
+
"""Dedicated update-check polling thread (spec §3.5).
|
|
1838
|
+
|
|
1839
|
+
Independent of the data-sync thread so it runs even under
|
|
1840
|
+
``--no-sync`` (codex review fix #5). Wakes once per
|
|
1841
|
+
:data:`UPDATE_DASHBOARD_CHECK_POLL_S` (30 min), consults
|
|
1842
|
+
:func:`_is_update_check_due`, runs :func:`_do_update_check` if so.
|
|
1843
|
+
The poll cadence is NOT the network-call frequency — actual TTL
|
|
1844
|
+
gate (default 24 h) lives in ``_is_update_check_due``. Disabling
|
|
1845
|
+
via ``update.check.enabled = false`` is honoured inside the gate
|
|
1846
|
+
so the thread becomes a no-op without needing teardown.
|
|
1847
|
+
|
|
1848
|
+
After a successful check, republishes the current snapshot via the
|
|
1849
|
+
SSE hub so long-open dashboard tabs in ``--no-sync`` mode pick up
|
|
1850
|
+
the fresh ``latest_version`` written to ``update-state.json``. The
|
|
1851
|
+
snapshot itself is unchanged — ``snapshot_to_envelope`` re-reads
|
|
1852
|
+
the state file per envelope build, so a bare publish is enough to
|
|
1853
|
+
refresh the badge for every live subscriber.
|
|
1854
|
+
"""
|
|
1855
|
+
|
|
1856
|
+
daemon = True
|
|
1857
|
+
|
|
1858
|
+
def __init__(
|
|
1859
|
+
self,
|
|
1860
|
+
stop_event: "threading.Event",
|
|
1861
|
+
*,
|
|
1862
|
+
hub: "SSEHub | None" = None,
|
|
1863
|
+
snapshot_ref: "_SnapshotRef | None" = None,
|
|
1864
|
+
) -> None:
|
|
1865
|
+
super().__init__(name="cctally-update-check")
|
|
1866
|
+
self._stop = stop_event
|
|
1867
|
+
self._hub = hub
|
|
1868
|
+
self._ref = snapshot_ref
|
|
1869
|
+
|
|
1870
|
+
def run(self) -> None:
|
|
1871
|
+
c = _cctally()
|
|
1872
|
+
while not self._stop.is_set():
|
|
1873
|
+
try:
|
|
1874
|
+
# Self-heal runs every tick (every 30 min by default),
|
|
1875
|
+
# NOT gated by `_is_update_check_due`'s 24h TTL. Catches
|
|
1876
|
+
# the case where the user upgrades the npm package
|
|
1877
|
+
# out-of-band (no `cctally update` invocation) — the
|
|
1878
|
+
# dashboard's brand-version label needs to reflect the
|
|
1879
|
+
# new binary without waiting up to 24h for the next
|
|
1880
|
+
# TTL probe. Re-publish the snapshot after a self-heal
|
|
1881
|
+
# write so live SSE subscribers pick up the corrected
|
|
1882
|
+
# `current_version` on their next envelope.
|
|
1883
|
+
healed_before = c._load_update_state()
|
|
1884
|
+
c._self_heal_current_version()
|
|
1885
|
+
healed_after = c._load_update_state()
|
|
1886
|
+
if (
|
|
1887
|
+
healed_before != healed_after
|
|
1888
|
+
and self._hub is not None
|
|
1889
|
+
and self._ref is not None
|
|
1890
|
+
):
|
|
1891
|
+
snap = self._ref.get()
|
|
1892
|
+
if snap is not None:
|
|
1893
|
+
self._hub.publish(snap)
|
|
1894
|
+
config = load_config()
|
|
1895
|
+
if c._is_update_check_due(config):
|
|
1896
|
+
c._do_update_check()
|
|
1897
|
+
if self._hub is not None and self._ref is not None:
|
|
1898
|
+
snap = self._ref.get()
|
|
1899
|
+
if snap is not None:
|
|
1900
|
+
self._hub.publish(snap)
|
|
1901
|
+
except Exception as e:
|
|
1902
|
+
# Log but never propagate — this thread must keep
|
|
1903
|
+
# ticking so a transient registry hiccup doesn't
|
|
1904
|
+
# silently disable the polling cadence for the rest
|
|
1905
|
+
# of the dashboard's lifetime.
|
|
1906
|
+
try:
|
|
1907
|
+
c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
1908
|
+
with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
|
|
1909
|
+
_log_update_event(
|
|
1910
|
+
log_fd, "CHECK_FAILED", error=str(e)[:200]
|
|
1911
|
+
)
|
|
1912
|
+
except Exception:
|
|
1913
|
+
pass
|
|
1914
|
+
self._stop.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def cmd_update(args) -> int:
|
|
1918
|
+
"""`cctally update` entry point — routes by mode flag (spec §4.1)."""
|
|
1919
|
+
c = _cctally()
|
|
1920
|
+
skip_arg = getattr(args, "skip", None)
|
|
1921
|
+
remind_arg = getattr(args, "remind_later", None)
|
|
1922
|
+
check_arg = getattr(args, "check", False)
|
|
1923
|
+
# NOTE: `args.install_version`, not `args.version` — the subparser's
|
|
1924
|
+
# `--version X.Y.Z` is `dest="install_version"` to avoid colliding
|
|
1925
|
+
# with the top-level `--version` flag handled in `main()`.
|
|
1926
|
+
version_arg = getattr(args, "install_version", None)
|
|
1927
|
+
modes = sum(bool(x) for x in [
|
|
1928
|
+
check_arg,
|
|
1929
|
+
skip_arg is not None,
|
|
1930
|
+
remind_arg is not None,
|
|
1931
|
+
])
|
|
1932
|
+
if modes > 1:
|
|
1933
|
+
print(
|
|
1934
|
+
"cctally update: --check / --skip / --remind-later are "
|
|
1935
|
+
"mutually exclusive",
|
|
1936
|
+
file=sys.stderr,
|
|
1937
|
+
)
|
|
1938
|
+
return 2
|
|
1939
|
+
if version_arg is not None and (
|
|
1940
|
+
check_arg or skip_arg is not None or remind_arg is not None
|
|
1941
|
+
):
|
|
1942
|
+
print(
|
|
1943
|
+
"cctally update: --version is install-mode only",
|
|
1944
|
+
file=sys.stderr,
|
|
1945
|
+
)
|
|
1946
|
+
return 2
|
|
1947
|
+
if skip_arg is not None:
|
|
1948
|
+
return c._do_update_skip(skip_arg)
|
|
1949
|
+
if remind_arg is not None:
|
|
1950
|
+
return c._do_update_remind_later(remind_arg)
|
|
1951
|
+
if check_arg:
|
|
1952
|
+
return c._do_update_check_user(
|
|
1953
|
+
force=getattr(args, "force", False),
|
|
1954
|
+
output_json=getattr(args, "json", False),
|
|
1955
|
+
)
|
|
1956
|
+
try:
|
|
1957
|
+
return c._do_update_install(
|
|
1958
|
+
version=version_arg,
|
|
1959
|
+
dry_run=getattr(args, "dry_run", False),
|
|
1960
|
+
output_json=getattr(args, "json", False),
|
|
1961
|
+
)
|
|
1962
|
+
except UpdateValidationError as e:
|
|
1963
|
+
# Input validation failure (invalid --version syntax,
|
|
1964
|
+
# --version+brew combo). rc=2 preserves the Task-4 contract.
|
|
1965
|
+
print(f"cctally update: {e}", file=sys.stderr)
|
|
1966
|
+
return 2
|
|
1967
|
+
except UpdateError as e:
|
|
1968
|
+
# Runtime / environment failure (unknown install method, npm
|
|
1969
|
+
# prefix not writable, lock contention). rc=1.
|
|
1970
|
+
print(f"cctally update: {e}", file=sys.stderr)
|
|
1971
|
+
return 1
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
# === Update banner (spec §4.2) =============================================
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
def _args_emit_json(args: argparse.Namespace) -> bool:
|
|
1978
|
+
"""True if this command's STDOUT will be JSON.
|
|
1979
|
+
|
|
1980
|
+
Subcommands declare --json with inconsistent dest names: most use
|
|
1981
|
+
dest="json" (default), but `diff` uses dest="emit_json". This helper
|
|
1982
|
+
centralizes the detection so banner routing doesn't accidentally
|
|
1983
|
+
corrupt JSON envelopes by missing a dest variant.
|
|
1984
|
+
|
|
1985
|
+
If you add a new subcommand with a non-default --json dest, add it
|
|
1986
|
+
here AND consider whether the convention should be normalized.
|
|
1987
|
+
"""
|
|
1988
|
+
return bool(
|
|
1989
|
+
getattr(args, "json", False)
|
|
1990
|
+
or getattr(args, "emit_json", False)
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def _args_emit_machine_stdout(args: argparse.Namespace) -> bool:
|
|
1995
|
+
"""True if STDOUT is consumed programmatically (JSON, status-line, etc).
|
|
1996
|
+
|
|
1997
|
+
Commands matching this predicate must NOT have any banner injected
|
|
1998
|
+
into their STDOUT, and stderr-routing isn't viable either (status-line
|
|
1999
|
+
integration is `$(cmd 2>/dev/null)` — stderr is discarded). The banner
|
|
2000
|
+
is suppressed entirely for these.
|
|
2001
|
+
|
|
2002
|
+
Currently: status_line only — extend here if new single-line scripted
|
|
2003
|
+
modes are added (e.g. a future --script or --raw flag).
|
|
2004
|
+
|
|
2005
|
+
JSON callers are NOT in this set — they get the banner on stderr
|
|
2006
|
+
(Q2 default), which scripts can grep without contaminating JSON.
|
|
2007
|
+
"""
|
|
2008
|
+
return bool(getattr(args, "status_line", False))
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
# Update-banner suppression set — parallel to ``_BANNER_SUPPRESSED_COMMANDS``
|
|
2012
|
+
# (migration banner) but with its own membership. ``update`` itself shouldn't
|
|
2013
|
+
# advertise an update; ``_update-check`` is the detached-refresh worker
|
|
2014
|
+
# (silent by contract). Other suppressions (record-usage, hook-tick, sync-week,
|
|
2015
|
+
# cache-sync, refresh-usage, tui, db) ride the existing migration set so
|
|
2016
|
+
# the two banners stay aligned for those commands.
|
|
2017
|
+
_UPDATE_BANNER_EXTRA_SUPPRESSED = frozenset({"_update-check", "update"})
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
def _semver_gt(a: str, b: str) -> bool:
|
|
2021
|
+
"""SemVer comparison via :func:`_release_parse_semver` + the
|
|
2022
|
+
SemVer-§11.4-aware sort key. ``a > b`` returns True when ``a`` is
|
|
2023
|
+
a strictly higher version. Raises :class:`ValueError` on either
|
|
2024
|
+
input being malformed (callers wrap in try/except)."""
|
|
2025
|
+
return _release_semver_sort_key(_release_parse_semver(a)) > \
|
|
2026
|
+
_release_semver_sort_key(_release_parse_semver(b))
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def _compute_effective_update_available(
|
|
2030
|
+
state: dict[str, Any] | None,
|
|
2031
|
+
suppress: dict[str, Any] | None,
|
|
2032
|
+
now_utc: "dt.datetime",
|
|
2033
|
+
) -> "tuple[bool, str | None]":
|
|
2034
|
+
"""Shared core of "is there a *real* pending update?"
|
|
2035
|
+
|
|
2036
|
+
Returns ``(available, reason)`` where ``reason`` is:
|
|
2037
|
+
- ``"missing_state"`` — current/latest unknown (no probe yet)
|
|
2038
|
+
- ``"no_newer"`` — latest is not strictly greater than current
|
|
2039
|
+
- ``"skipped"`` — user has skipped the latest version
|
|
2040
|
+
- ``"reminded"`` — user has deferred and the window is still active
|
|
2041
|
+
- ``None`` — available (warn-worthy)
|
|
2042
|
+
|
|
2043
|
+
Single source of truth for both ``_should_show_update_banner`` and
|
|
2044
|
+
``cctally doctor``'s ``safety.update_available`` check. Keeping this
|
|
2045
|
+
shared avoids the bug where doctor would advertise an update the
|
|
2046
|
+
banner suppresses (see review finding "Respect skipped/reminded
|
|
2047
|
+
updates"). Malformed ``remind_after`` fails open — matches the
|
|
2048
|
+
banner's pre-extraction posture: better to show a real reminder
|
|
2049
|
+
than to silently drop one because of a corrupt suppress file.
|
|
2050
|
+
"""
|
|
2051
|
+
c = _cctally()
|
|
2052
|
+
if state is None:
|
|
2053
|
+
return False, "missing_state"
|
|
2054
|
+
cur = state.get("current_version")
|
|
2055
|
+
lat = state.get("latest_version")
|
|
2056
|
+
if not cur or not lat:
|
|
2057
|
+
return False, "missing_state"
|
|
2058
|
+
try:
|
|
2059
|
+
if not c._semver_gt(lat, cur):
|
|
2060
|
+
return False, "no_newer"
|
|
2061
|
+
except ValueError:
|
|
2062
|
+
return False, "no_newer"
|
|
2063
|
+
sup = suppress or {}
|
|
2064
|
+
if lat in sup.get("skipped_versions", []):
|
|
2065
|
+
return False, "skipped"
|
|
2066
|
+
remind = sup.get("remind_after")
|
|
2067
|
+
if remind is not None:
|
|
2068
|
+
try:
|
|
2069
|
+
# Hide while the deferral is active AND the user-pinned version
|
|
2070
|
+
# is still the latest. A newer drop overrides the deferral.
|
|
2071
|
+
if not c._semver_gt(lat, remind["version"]):
|
|
2072
|
+
until = dt.datetime.fromisoformat(remind["until_utc"])
|
|
2073
|
+
if now_utc < until:
|
|
2074
|
+
return False, "reminded"
|
|
2075
|
+
except (KeyError, ValueError):
|
|
2076
|
+
# Malformed remind_after: fail-open. Better to surface a
|
|
2077
|
+
# real update than to silently drop it.
|
|
2078
|
+
pass
|
|
2079
|
+
return True, None
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
def _should_show_update_banner(
|
|
2083
|
+
command: str | None,
|
|
2084
|
+
args: argparse.Namespace,
|
|
2085
|
+
state: dict[str, Any] | None,
|
|
2086
|
+
suppress: dict[str, Any],
|
|
2087
|
+
config: dict[str, Any],
|
|
2088
|
+
) -> bool:
|
|
2089
|
+
"""Return True iff a one-line update banner should land on stderr
|
|
2090
|
+
after this command's output (spec §4.2).
|
|
2091
|
+
|
|
2092
|
+
Composition is the key invariant: the predicate **must** delegate
|
|
2093
|
+
machine-mode detection to the existing helpers
|
|
2094
|
+
(:func:`_args_emit_json`, :func:`_args_emit_machine_stdout`) so a
|
|
2095
|
+
new ``--json`` dest variant or status-line flag added to any
|
|
2096
|
+
subcommand inherits the suppression automatically. Adding a parallel
|
|
2097
|
+
list here would silently regress that invariant — the spec
|
|
2098
|
+
amendment for Codex finding #8 codifies this.
|
|
2099
|
+
|
|
2100
|
+
Semver + skipped + remind logic is delegated to
|
|
2101
|
+
:func:`_compute_effective_update_available` so ``cctally doctor``
|
|
2102
|
+
stays in lockstep with this predicate.
|
|
2103
|
+
"""
|
|
2104
|
+
c = _cctally()
|
|
2105
|
+
if command in c._BANNER_SUPPRESSED_COMMANDS or command in _UPDATE_BANNER_EXTRA_SUPPRESSED:
|
|
2106
|
+
return False
|
|
2107
|
+
if c._args_emit_json(args):
|
|
2108
|
+
return False
|
|
2109
|
+
if c._args_emit_machine_stdout(args):
|
|
2110
|
+
return False
|
|
2111
|
+
if getattr(args, "format", None) is not None:
|
|
2112
|
+
return False
|
|
2113
|
+
if not sys.stderr.isatty():
|
|
2114
|
+
return False
|
|
2115
|
+
if not config.get("update", {}).get("check", {}).get("enabled", True):
|
|
2116
|
+
return False
|
|
2117
|
+
available, _ = c._compute_effective_update_available(state, suppress, c._now_utc())
|
|
2118
|
+
return available
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def _format_update_banner(state: dict[str, Any]) -> str:
|
|
2122
|
+
"""One-line stderr banner. Spec §4.2.
|
|
2123
|
+
|
|
2124
|
+
Includes the dismissal recipe inline so the user never has to
|
|
2125
|
+
consult docs to silence it.
|
|
2126
|
+
"""
|
|
2127
|
+
cur = state["current_version"]
|
|
2128
|
+
lat = state["latest_version"]
|
|
2129
|
+
return (
|
|
2130
|
+
f"↑ cctally {lat} available (you're on {cur}). "
|
|
2131
|
+
f"Run `cctally update`. Skip: cctally update --skip {lat}"
|
|
2132
|
+
)
|