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.
- package/CHANGELOG.md +27 -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 +5403 -0
- package/bin/_cctally_db.py +1837 -0
- package/bin/_cctally_record.py +2305 -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 +4487 -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 +441 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +137 -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_subscription_weeks.py +492 -0
- package/bin/cctally +11694 -35448
- package/package.json +24 -1
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""`config.json` reader, writer, lock, validators, and `cctally config` entry point.
|
|
2
|
+
|
|
3
|
+
Eager I/O sibling: bin/cctally loads this at startup and re-exports
|
|
4
|
+
every public symbol so bare-name callers (the dashboard `/api/settings`
|
|
5
|
+
handler, `cmd_record_usage` reading `load_config()`, `cmd_refresh_usage`
|
|
6
|
+
gating on `_get_oauth_usage_config(load_config())`, the update-check
|
|
7
|
+
predicate, `sync-week`, …) all resolve unchanged. Tests that mock
|
|
8
|
+
`load_config` via ``monkeypatch.setitem(ns, "load_config", …)`` still
|
|
9
|
+
work because Python's bare-name lookup inside non-extracted bin/cctally
|
|
10
|
+
callers resolves in bin/cctally's namespace (where the re-export lives).
|
|
11
|
+
|
|
12
|
+
What stays in bin/cctally:
|
|
13
|
+
- ``_ALERTS_BAD_CONFIG_WARNED`` + ``_warn_alerts_bad_config_once`` —
|
|
14
|
+
alerts-coupled warn-once flag/helper; the alerts-config readers
|
|
15
|
+
(``_get_alerts_config`` / ``_AlertsConfigError``) still live in
|
|
16
|
+
bin/cctally and these two travel with that block.
|
|
17
|
+
- ``CONFIG_PATH`` / ``CONFIG_LOCK_PATH`` path constants (spec §86–92
|
|
18
|
+
keeps every path constant in bin/cctally so monkeypatched
|
|
19
|
+
`cctally.CONFIG_PATH = …` redirects propagate everywhere).
|
|
20
|
+
- ``eprint`` / ``ensure_dirs`` / ``DEFAULT_WEEK_START`` ubiquitous
|
|
21
|
+
helpers/constants.
|
|
22
|
+
- All validator/normalizer primitives (``normalize_display_tz_value``,
|
|
23
|
+
``_get_alerts_config``, ``_AlertsConfigError``,
|
|
24
|
+
``_normalize_alerts_enabled_value``, ``_validate_dashboard_bind_value``,
|
|
25
|
+
``_normalize_update_check_enabled_value``,
|
|
26
|
+
``_validate_update_check_ttl_hours_value``,
|
|
27
|
+
``UPDATE_DEFAULT_TTL_HOURS``, ``get_display_tz_pref``) — these stay
|
|
28
|
+
near the subsystem they belong to; we reach them via the
|
|
29
|
+
``_cctally()`` accessor (call-time lookup so test monkeypatches on
|
|
30
|
+
bin/cctally's namespace still propagate, per spec §5.2).
|
|
31
|
+
|
|
32
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import contextlib
|
|
38
|
+
import fcntl
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import secrets
|
|
42
|
+
import sys
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cctally():
|
|
47
|
+
"""Resolve the current `cctally` module at call-time (spec §5.5)."""
|
|
48
|
+
return sys.modules["cctally"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_CONFIG_CORRUPT_WARNED = False # one-shot warn flag for load_config
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _warn_config_corrupt_once(reason: str) -> None:
|
|
55
|
+
"""Emit a single stderr warning per process when config.json is
|
|
56
|
+
unreadable. Mirrors the warn-once pattern used by
|
|
57
|
+
`_DISPLAY_TZ_BAD_CONFIG_WARNED` for malformed display.tz values.
|
|
58
|
+
"""
|
|
59
|
+
global _CONFIG_CORRUPT_WARNED
|
|
60
|
+
if _CONFIG_CORRUPT_WARNED:
|
|
61
|
+
return
|
|
62
|
+
_CONFIG_CORRUPT_WARNED = True
|
|
63
|
+
c = _cctally()
|
|
64
|
+
c.eprint(
|
|
65
|
+
f"warning: ignoring corrupt {c.CONFIG_PATH} ({reason}); "
|
|
66
|
+
"using in-memory defaults"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _default_config_data() -> dict[str, Any]:
|
|
71
|
+
c = _cctally()
|
|
72
|
+
return {
|
|
73
|
+
"collector": {
|
|
74
|
+
"host": "127.0.0.1",
|
|
75
|
+
"port": 17321,
|
|
76
|
+
"token": secrets.token_hex(16),
|
|
77
|
+
"week_start": c.DEFAULT_WEEK_START,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _try_read_config() -> "dict[str, Any] | None":
|
|
83
|
+
"""Read+parse CONFIG_PATH. Returns None when missing OR corrupt.
|
|
84
|
+
|
|
85
|
+
Corrupt cases (non-JSON or non-object root) emit a one-shot stderr
|
|
86
|
+
warning and return None — caller decides whether to fall back to
|
|
87
|
+
in-memory defaults or to overwrite with fresh defaults under the
|
|
88
|
+
config writer lock.
|
|
89
|
+
"""
|
|
90
|
+
c = _cctally()
|
|
91
|
+
if not c.CONFIG_PATH.exists():
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
raw = c.CONFIG_PATH.read_text(encoding="utf-8")
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
_warn_config_corrupt_once(f"read failed: {exc}")
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(raw)
|
|
100
|
+
except json.JSONDecodeError as exc:
|
|
101
|
+
_warn_config_corrupt_once(f"JSONDecodeError: {exc}")
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(data, dict):
|
|
104
|
+
_warn_config_corrupt_once("non-object JSON root")
|
|
105
|
+
return None
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextlib.contextmanager
|
|
110
|
+
def config_writer_lock():
|
|
111
|
+
"""Exclusive fcntl.flock around config.json read-modify-write.
|
|
112
|
+
|
|
113
|
+
Mirrors the cache.db.lock pattern (see sync_cache) but uses blocking
|
|
114
|
+
LOCK_EX rather than LOCK_NB: config writes are millisecond-scale, so
|
|
115
|
+
a brief wait is preferable to silently dropping a writer's update.
|
|
116
|
+
Used by:
|
|
117
|
+
- cctally config set / unset (CLI path)
|
|
118
|
+
- dashboard POST /api/settings handler
|
|
119
|
+
- load_config first-run create path
|
|
120
|
+
External readers (load_config in the no-write path) do NOT acquire
|
|
121
|
+
this lock — atomic os.replace in save_config guarantees readers see
|
|
122
|
+
either the pre-rename or post-rename file, never partial bytes.
|
|
123
|
+
"""
|
|
124
|
+
c = _cctally()
|
|
125
|
+
c.ensure_dirs()
|
|
126
|
+
c.CONFIG_LOCK_PATH.touch()
|
|
127
|
+
fh = open(c.CONFIG_LOCK_PATH, "w")
|
|
128
|
+
try:
|
|
129
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
130
|
+
try:
|
|
131
|
+
yield
|
|
132
|
+
finally:
|
|
133
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
134
|
+
finally:
|
|
135
|
+
fh.close()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_config() -> dict[str, Any]:
|
|
139
|
+
"""Read config.json, falling back to in-memory defaults on corruption.
|
|
140
|
+
|
|
141
|
+
Concurrent-safety: readers see either the pre-rename or post-rename
|
|
142
|
+
contents thanks to save_config's atomic os.replace. On corrupt or
|
|
143
|
+
non-object JSON, emits a one-shot stderr warning and returns
|
|
144
|
+
in-memory defaults WITHOUT re-saving — the next legitimate
|
|
145
|
+
save_config call (under config_writer_lock) will overwrite the bad
|
|
146
|
+
bytes atomically. On first run (file missing), creates the file
|
|
147
|
+
with a fresh collector token under the writer lock so two parallel
|
|
148
|
+
first-run processes don't clobber each other.
|
|
149
|
+
|
|
150
|
+
DEADLOCK NOTE: `fcntl.flock` is per-fd even within the same
|
|
151
|
+
process. Callers that already hold config_writer_lock MUST use
|
|
152
|
+
`_load_config_unlocked()` instead — re-entering this function
|
|
153
|
+
inside an outer lock would block forever (verified during issue
|
|
154
|
+
#17 fix).
|
|
155
|
+
"""
|
|
156
|
+
c = _cctally()
|
|
157
|
+
c.ensure_dirs()
|
|
158
|
+
parsed = _try_read_config()
|
|
159
|
+
if parsed is not None:
|
|
160
|
+
return parsed
|
|
161
|
+
|
|
162
|
+
if c.CONFIG_PATH.exists():
|
|
163
|
+
# Corrupt file: warning already emitted by _try_read_config.
|
|
164
|
+
# Return in-memory defaults; do NOT persist — a transient
|
|
165
|
+
# corruption is recoverable by the next legitimate
|
|
166
|
+
# `cctally config set` (which now runs under the writer lock
|
|
167
|
+
# with an atomic write).
|
|
168
|
+
return _default_config_data()
|
|
169
|
+
|
|
170
|
+
# First-run create: hold the writer lock so two simultaneous
|
|
171
|
+
# first-runners agree on a single committed token.
|
|
172
|
+
with config_writer_lock():
|
|
173
|
+
parsed = _try_read_config()
|
|
174
|
+
if parsed is not None:
|
|
175
|
+
return parsed
|
|
176
|
+
data = _default_config_data()
|
|
177
|
+
save_config(data)
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _load_config_unlocked() -> dict[str, Any]:
|
|
182
|
+
"""`load_config` variant for use INSIDE an already-held
|
|
183
|
+
config_writer_lock. Skips the first-run lock acquisition (which
|
|
184
|
+
would self-deadlock — `fcntl.flock` is per-fd, not per-process)
|
|
185
|
+
and never persists: the writer that already holds the lock will
|
|
186
|
+
do its own save_config call atomically. Corrupt-file path returns
|
|
187
|
+
in-memory defaults (caller's save will overwrite cleanly).
|
|
188
|
+
"""
|
|
189
|
+
c = _cctally()
|
|
190
|
+
c.ensure_dirs()
|
|
191
|
+
parsed = _try_read_config()
|
|
192
|
+
if parsed is not None:
|
|
193
|
+
return parsed
|
|
194
|
+
return _default_config_data()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def save_config(data: dict[str, Any]) -> None:
|
|
198
|
+
"""Persist `data` to config.json atomically.
|
|
199
|
+
|
|
200
|
+
Writes JSON to a sibling temp path (unique per writer PID so two
|
|
201
|
+
unsynchronized writers cannot clobber each other's tmp), fsyncs the
|
|
202
|
+
contents to disk, then `os.replace`s onto CONFIG_PATH. POSIX
|
|
203
|
+
rename(2) is atomic on the same filesystem, so concurrent readers
|
|
204
|
+
see either the old or the new file contents — never partial bytes.
|
|
205
|
+
|
|
206
|
+
Concurrent writers must additionally serialize via
|
|
207
|
+
config_writer_lock; the atomic rename alone protects readers but
|
|
208
|
+
not the read-modify-write semantics of `cctally config set`.
|
|
209
|
+
"""
|
|
210
|
+
c = _cctally()
|
|
211
|
+
c.ensure_dirs()
|
|
212
|
+
payload = (json.dumps(data, indent=2) + "\n").encode("utf-8")
|
|
213
|
+
tmp = c.CONFIG_PATH.with_name(f"{c.CONFIG_PATH.name}.tmp.{os.getpid()}")
|
|
214
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
215
|
+
try:
|
|
216
|
+
os.write(fd, payload)
|
|
217
|
+
os.fsync(fd)
|
|
218
|
+
finally:
|
|
219
|
+
os.close(fd)
|
|
220
|
+
os.replace(str(tmp), str(c.CONFIG_PATH))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
ALLOWED_CONFIG_KEYS = (
|
|
224
|
+
"display.tz",
|
|
225
|
+
"alerts.enabled",
|
|
226
|
+
"dashboard.bind",
|
|
227
|
+
"update.check.enabled",
|
|
228
|
+
"update.check.ttl_hours",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
233
|
+
"""Get/set/unset persisted user preferences in config.json.
|
|
234
|
+
|
|
235
|
+
Currently the only allowed key is "display.tz". Future keys join
|
|
236
|
+
via ALLOWED_CONFIG_KEYS without changing the gate.
|
|
237
|
+
|
|
238
|
+
Read-modify-write paths (set/unset) acquire config_writer_lock and
|
|
239
|
+
re-read config.json INSIDE the lock so concurrent invocations are
|
|
240
|
+
serialized; a stale pre-lock copy would lose updates.
|
|
241
|
+
"""
|
|
242
|
+
c = _cctally()
|
|
243
|
+
action = args.action
|
|
244
|
+
|
|
245
|
+
if action == "get":
|
|
246
|
+
return _cmd_config_get(args, c.load_config())
|
|
247
|
+
if action == "set":
|
|
248
|
+
return _cmd_config_set(args)
|
|
249
|
+
if action == "unset":
|
|
250
|
+
return _cmd_config_unset(args)
|
|
251
|
+
c.eprint(f"cctally config: unknown action {action!r}")
|
|
252
|
+
return 2
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _config_known_value(config: dict, key: str) -> "object":
|
|
256
|
+
"""Return the stored value for one of ALLOWED_CONFIG_KEYS.
|
|
257
|
+
|
|
258
|
+
For ``display.tz`` returns the canonicalized form via
|
|
259
|
+
``get_display_tz_pref`` so the user sees what cctally actually
|
|
260
|
+
applies. For ``alerts.enabled`` returns the validated boolean from
|
|
261
|
+
``_get_alerts_config`` (defaults to False when unset). Returns
|
|
262
|
+
``None`` only for unknown keys (caller treats as "missing").
|
|
263
|
+
"""
|
|
264
|
+
c = _cctally()
|
|
265
|
+
if key == "display.tz":
|
|
266
|
+
return c.get_display_tz_pref(config)
|
|
267
|
+
if key == "alerts.enabled":
|
|
268
|
+
return bool(c._get_alerts_config(config)["enabled"])
|
|
269
|
+
if key == "dashboard.bind":
|
|
270
|
+
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
271
|
+
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
272
|
+
# or per-call `--host 0.0.0.0`.
|
|
273
|
+
block = config.get("dashboard") if isinstance(config, dict) else None
|
|
274
|
+
if not isinstance(block, dict):
|
|
275
|
+
block = {}
|
|
276
|
+
stored = block.get("bind")
|
|
277
|
+
if not stored:
|
|
278
|
+
return "loopback"
|
|
279
|
+
try:
|
|
280
|
+
return c._validate_dashboard_bind_value(stored)
|
|
281
|
+
except ValueError:
|
|
282
|
+
# Hand-edited junk: surface the default rather than the bad value;
|
|
283
|
+
# `cmd_dashboard` warns at server-start when it hits the same path.
|
|
284
|
+
return "loopback"
|
|
285
|
+
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
286
|
+
# Defaults mirror `_is_update_check_due` (True / 24 hours).
|
|
287
|
+
# Hand-edited junk surfaces as the default — matches dashboard.bind.
|
|
288
|
+
update_block = (
|
|
289
|
+
config.get("update") if isinstance(config, dict) else None
|
|
290
|
+
)
|
|
291
|
+
if not isinstance(update_block, dict):
|
|
292
|
+
update_block = {}
|
|
293
|
+
check_block = update_block.get("check")
|
|
294
|
+
if not isinstance(check_block, dict):
|
|
295
|
+
check_block = {}
|
|
296
|
+
if key == "update.check.enabled":
|
|
297
|
+
stored = check_block.get("enabled", True)
|
|
298
|
+
return bool(stored) if isinstance(stored, bool) else True
|
|
299
|
+
# update.check.ttl_hours
|
|
300
|
+
stored = check_block.get("ttl_hours", c.UPDATE_DEFAULT_TTL_HOURS)
|
|
301
|
+
try:
|
|
302
|
+
return c._validate_update_check_ttl_hours_value(stored)
|
|
303
|
+
except ValueError:
|
|
304
|
+
return c.UPDATE_DEFAULT_TTL_HOURS
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
309
|
+
c = _cctally()
|
|
310
|
+
key = args.key
|
|
311
|
+
if key is not None and key not in ALLOWED_CONFIG_KEYS:
|
|
312
|
+
c.eprint(f"cctally config: unknown config key {key!r}")
|
|
313
|
+
return 2
|
|
314
|
+
pairs: "list[tuple[str, object]]" = []
|
|
315
|
+
if key is None:
|
|
316
|
+
for k in ALLOWED_CONFIG_KEYS:
|
|
317
|
+
v = _config_known_value(config, k)
|
|
318
|
+
pairs.append((k, v if v is not None else ""))
|
|
319
|
+
else:
|
|
320
|
+
v = _config_known_value(config, key)
|
|
321
|
+
pairs.append((key, v if v is not None else ""))
|
|
322
|
+
|
|
323
|
+
if getattr(args, "emit_json", False):
|
|
324
|
+
# Walk every dot-delimited segment so keys deeper than two
|
|
325
|
+
# segments (e.g. `update.check.enabled`) nest correctly. The
|
|
326
|
+
# earlier `partition` form collapsed three-segment keys into
|
|
327
|
+
# a flat tail (`{"update": {"check.enabled": ...}}`) and
|
|
328
|
+
# diverged from `config set --json` / on-disk shape.
|
|
329
|
+
out: "dict[str, object]" = {}
|
|
330
|
+
for k, v in pairs:
|
|
331
|
+
segments = k.split(".")
|
|
332
|
+
node: dict = out
|
|
333
|
+
for seg in segments[:-1]:
|
|
334
|
+
node = node.setdefault(seg, {})
|
|
335
|
+
node[segments[-1]] = v
|
|
336
|
+
print(json.dumps(out, indent=2))
|
|
337
|
+
else:
|
|
338
|
+
for k, v in pairs:
|
|
339
|
+
# Preserve canonical bool stringification (true/false) so
|
|
340
|
+
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
341
|
+
if isinstance(v, bool):
|
|
342
|
+
rendered = "true" if v else "false"
|
|
343
|
+
else:
|
|
344
|
+
rendered = str(v)
|
|
345
|
+
print(f"{k}={rendered}")
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
350
|
+
c = _cctally()
|
|
351
|
+
key, raw = args.key, args.value
|
|
352
|
+
if key not in ALLOWED_CONFIG_KEYS:
|
|
353
|
+
c.eprint(f"cctally config: unknown config key {key!r}")
|
|
354
|
+
return 2
|
|
355
|
+
if key == "display.tz":
|
|
356
|
+
try:
|
|
357
|
+
canonical = c.normalize_display_tz_value(raw)
|
|
358
|
+
except ValueError:
|
|
359
|
+
c.eprint(f"cctally config: invalid IANA zone {raw!r}")
|
|
360
|
+
return 2
|
|
361
|
+
with config_writer_lock():
|
|
362
|
+
config = _load_config_unlocked()
|
|
363
|
+
config.setdefault("display", {})["tz"] = canonical
|
|
364
|
+
save_config(config)
|
|
365
|
+
if getattr(args, "emit_json", False):
|
|
366
|
+
print(json.dumps({"display": {"tz": canonical}}, indent=2))
|
|
367
|
+
else:
|
|
368
|
+
print(f"display.tz={canonical}")
|
|
369
|
+
return 0
|
|
370
|
+
if key == "alerts.enabled":
|
|
371
|
+
try:
|
|
372
|
+
normalized = c._normalize_alerts_enabled_value(raw)
|
|
373
|
+
except ValueError as exc:
|
|
374
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
375
|
+
return 2
|
|
376
|
+
# Read-modify-write under config_writer_lock, preserving any
|
|
377
|
+
# other alerts.* keys (e.g. user-customized weekly_thresholds).
|
|
378
|
+
# _load_config_unlocked is mandatory here — calling load_config
|
|
379
|
+
# would self-deadlock on the same fcntl.flock fd.
|
|
380
|
+
with config_writer_lock():
|
|
381
|
+
config = _load_config_unlocked()
|
|
382
|
+
# Pre-merge type guard: a hand-edited config with a non-dict
|
|
383
|
+
# alerts block (e.g. ``"alerts": "bad"``) makes the dict()
|
|
384
|
+
# copy below raise ValueError before _get_alerts_config can
|
|
385
|
+
# surface a controlled error. Surface the same message
|
|
386
|
+
# _AlertsConfigError would so the user sees a recoverable
|
|
387
|
+
# rc=2 instead of an uncaught ValueError.
|
|
388
|
+
existing_alerts = config.get("alerts")
|
|
389
|
+
if existing_alerts is not None and not isinstance(
|
|
390
|
+
existing_alerts, dict
|
|
391
|
+
):
|
|
392
|
+
print(
|
|
393
|
+
"cctally: alerts config error: alerts must be an object",
|
|
394
|
+
file=sys.stderr,
|
|
395
|
+
)
|
|
396
|
+
return 2
|
|
397
|
+
alerts_block = dict(existing_alerts or {})
|
|
398
|
+
alerts_block["enabled"] = normalized
|
|
399
|
+
# Validate the would-be merged block before persisting so
|
|
400
|
+
# we never write a config that fails subsequent reads.
|
|
401
|
+
try:
|
|
402
|
+
c._get_alerts_config({**config, "alerts": alerts_block})
|
|
403
|
+
except c._AlertsConfigError as exc:
|
|
404
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
405
|
+
return 2
|
|
406
|
+
config["alerts"] = alerts_block
|
|
407
|
+
save_config(config)
|
|
408
|
+
if getattr(args, "emit_json", False):
|
|
409
|
+
print(json.dumps({"alerts": {"enabled": normalized}}, indent=2))
|
|
410
|
+
else:
|
|
411
|
+
print(f"alerts.enabled={'true' if normalized else 'false'}")
|
|
412
|
+
return 0
|
|
413
|
+
if key == "dashboard.bind":
|
|
414
|
+
# Validation rejects whitespace / empty / non-string up front;
|
|
415
|
+
# write proceeds under config_writer_lock with _load_config_unlocked
|
|
416
|
+
# (calling load_config inside the writer-lock self-deadlocks per the
|
|
417
|
+
# CLAUDE.md gotcha — fcntl.flock is per-fd, not per-process).
|
|
418
|
+
try:
|
|
419
|
+
canonical = c._validate_dashboard_bind_value(raw)
|
|
420
|
+
except ValueError as exc:
|
|
421
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
422
|
+
return 2
|
|
423
|
+
with config_writer_lock():
|
|
424
|
+
config = _load_config_unlocked()
|
|
425
|
+
existing = config.get("dashboard")
|
|
426
|
+
if existing is not None and not isinstance(existing, dict):
|
|
427
|
+
print(
|
|
428
|
+
"cctally: dashboard config error: dashboard must be an object",
|
|
429
|
+
file=sys.stderr,
|
|
430
|
+
)
|
|
431
|
+
return 2
|
|
432
|
+
block = dict(existing or {})
|
|
433
|
+
block["bind"] = canonical
|
|
434
|
+
config["dashboard"] = block
|
|
435
|
+
save_config(config)
|
|
436
|
+
if getattr(args, "emit_json", False):
|
|
437
|
+
print(json.dumps({"dashboard": {"bind": canonical}}, indent=2))
|
|
438
|
+
else:
|
|
439
|
+
print(f"dashboard.bind={canonical}")
|
|
440
|
+
return 0
|
|
441
|
+
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
442
|
+
# Validate first; rejection short-circuits before lock acquisition.
|
|
443
|
+
if key == "update.check.enabled":
|
|
444
|
+
try:
|
|
445
|
+
normalized: object = c._normalize_update_check_enabled_value(raw)
|
|
446
|
+
except ValueError as exc:
|
|
447
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
448
|
+
return 2
|
|
449
|
+
inner_key = "enabled"
|
|
450
|
+
else:
|
|
451
|
+
try:
|
|
452
|
+
normalized = c._validate_update_check_ttl_hours_value(raw)
|
|
453
|
+
except ValueError as exc:
|
|
454
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
455
|
+
return 2
|
|
456
|
+
inner_key = "ttl_hours"
|
|
457
|
+
with config_writer_lock():
|
|
458
|
+
config = _load_config_unlocked()
|
|
459
|
+
existing_update = config.get("update")
|
|
460
|
+
if existing_update is not None and not isinstance(existing_update, dict):
|
|
461
|
+
print(
|
|
462
|
+
"cctally: update config error: update must be an object",
|
|
463
|
+
file=sys.stderr,
|
|
464
|
+
)
|
|
465
|
+
return 2
|
|
466
|
+
update_block = dict(existing_update or {})
|
|
467
|
+
existing_check = update_block.get("check")
|
|
468
|
+
if existing_check is not None and not isinstance(existing_check, dict):
|
|
469
|
+
print(
|
|
470
|
+
"cctally: update config error: update.check must be an object",
|
|
471
|
+
file=sys.stderr,
|
|
472
|
+
)
|
|
473
|
+
return 2
|
|
474
|
+
check_block = dict(existing_check or {})
|
|
475
|
+
check_block[inner_key] = normalized
|
|
476
|
+
update_block["check"] = check_block
|
|
477
|
+
config["update"] = update_block
|
|
478
|
+
save_config(config)
|
|
479
|
+
if getattr(args, "emit_json", False):
|
|
480
|
+
print(json.dumps({"update": {"check": {inner_key: normalized}}}, indent=2))
|
|
481
|
+
else:
|
|
482
|
+
if isinstance(normalized, bool):
|
|
483
|
+
rendered = "true" if normalized else "false"
|
|
484
|
+
else:
|
|
485
|
+
rendered = str(normalized)
|
|
486
|
+
print(f"{key}={rendered}")
|
|
487
|
+
return 0
|
|
488
|
+
return 2 # unreachable given the gate above
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
492
|
+
c = _cctally()
|
|
493
|
+
key = args.key
|
|
494
|
+
if key not in ALLOWED_CONFIG_KEYS:
|
|
495
|
+
c.eprint(f"cctally config: unknown config key {key!r}")
|
|
496
|
+
return 2
|
|
497
|
+
if key == "display.tz":
|
|
498
|
+
with config_writer_lock():
|
|
499
|
+
config = _load_config_unlocked()
|
|
500
|
+
block = config.get("display")
|
|
501
|
+
if isinstance(block, dict) and "tz" in block:
|
|
502
|
+
del block["tz"]
|
|
503
|
+
if not block:
|
|
504
|
+
config.pop("display", None)
|
|
505
|
+
save_config(config)
|
|
506
|
+
# idempotent: silent on missing key
|
|
507
|
+
return 0
|
|
508
|
+
if key == "alerts.enabled":
|
|
509
|
+
# Mirror the display.tz branch: writer-lock + _load_config_unlocked
|
|
510
|
+
# (NOT load_config — fcntl.flock is per-fd so re-entry would
|
|
511
|
+
# self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
|
|
512
|
+
# `enabled` key preserves any user-customized threshold lists
|
|
513
|
+
# (`weekly_thresholds`, `five_hour_thresholds`); the read-time
|
|
514
|
+
# validator (`_get_alerts_config`) re-applies the canonical
|
|
515
|
+
# default of `enabled = False` for the missing key on next get.
|
|
516
|
+
with config_writer_lock():
|
|
517
|
+
config = _load_config_unlocked()
|
|
518
|
+
block = config.get("alerts")
|
|
519
|
+
if isinstance(block, dict) and "enabled" in block:
|
|
520
|
+
del block["enabled"]
|
|
521
|
+
if not block:
|
|
522
|
+
config.pop("alerts", None)
|
|
523
|
+
save_config(config)
|
|
524
|
+
# idempotent: silent on missing key
|
|
525
|
+
return 0
|
|
526
|
+
if key == "dashboard.bind":
|
|
527
|
+
# Mirror the display.tz / alerts.enabled branches: writer-lock +
|
|
528
|
+
# _load_config_unlocked. Drops only the `bind` key; if `dashboard`
|
|
529
|
+
# ends up empty, drop the parent block too so config.json stays tidy.
|
|
530
|
+
with config_writer_lock():
|
|
531
|
+
config = _load_config_unlocked()
|
|
532
|
+
block = config.get("dashboard")
|
|
533
|
+
if isinstance(block, dict) and "bind" in block:
|
|
534
|
+
del block["bind"]
|
|
535
|
+
if not block:
|
|
536
|
+
config.pop("dashboard", None)
|
|
537
|
+
save_config(config)
|
|
538
|
+
# idempotent: silent on missing key
|
|
539
|
+
return 0
|
|
540
|
+
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
541
|
+
# Mirror the dashboard.bind branch: drop the leaf, then prune
|
|
542
|
+
# empty `check` and empty `update` so config.json stays tidy.
|
|
543
|
+
inner_key = (
|
|
544
|
+
"enabled" if key == "update.check.enabled" else "ttl_hours"
|
|
545
|
+
)
|
|
546
|
+
with config_writer_lock():
|
|
547
|
+
config = _load_config_unlocked()
|
|
548
|
+
update_block = config.get("update")
|
|
549
|
+
if isinstance(update_block, dict):
|
|
550
|
+
check_block = update_block.get("check")
|
|
551
|
+
if isinstance(check_block, dict) and inner_key in check_block:
|
|
552
|
+
del check_block[inner_key]
|
|
553
|
+
if not check_block:
|
|
554
|
+
del update_block["check"]
|
|
555
|
+
if not update_block:
|
|
556
|
+
config.pop("update", None)
|
|
557
|
+
save_config(config)
|
|
558
|
+
# idempotent: silent on missing key
|
|
559
|
+
return 0
|
|
560
|
+
return 2 # unreachable given the gate above
|