cctally 1.7.0 → 1.7.2

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.
@@ -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
+ )