mac-upkeep 2.2.1__tar.gz → 2.3.0__tar.gz
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.
- mac_upkeep-2.3.0/.release-please-manifest.json +3 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/CHANGELOG.md +9 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/CLAUDE.md +5 -2
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/PKG-INFO +4 -4
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/README.md +3 -3
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/docs/reusable-patterns.md +1 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/llms.txt +1 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/pyproject.toml +1 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/cli.py +172 -16
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/tasks.py +39 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/tests/test_cli.py +58 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/tests/test_tasks.py +68 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/uv.lock +1 -1
- mac_upkeep-2.2.1/.release-please-manifest.json +0 -3
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/.github/workflows/release.yml +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/.github/workflows/test.yml +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/.gitignore +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/CONTRIBUTING.md +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/LICENSE +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/demo/demo.gif +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/demo/record.sh +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/release-please-config.json +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/__init__.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/config.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/defaults.toml +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/notify.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/output.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/src/mac_upkeep/py.typed +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/tests/__init__.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/tests/test_config.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/tests/test_notify.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.3.0}/tests/test_output.py +0 -0
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.1...v2.3.0) (2026-04-13)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add next scheduled run visibility to tasks and run ([6317ce1](https://github.com/calvindotsg/mac-upkeep/commit/6317ce18c041a8f29a8e8534a061bfd3aec24343))
|
|
9
|
+
* add next scheduled run visibility to tasks and run ([34dbe93](https://github.com/calvindotsg/mac-upkeep/commit/34dbe93fab3781d7ce669c852b6c06eb0ab8d5ca))
|
|
10
|
+
* redesign status command as scheduling dashboard ([#34](https://github.com/calvindotsg/mac-upkeep/issues/34)) ([86ca27c](https://github.com/calvindotsg/mac-upkeep/commit/86ca27cb7f7b1fd8878996552246061ebdd6d598))
|
|
11
|
+
|
|
3
12
|
## [2.2.1](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.0...v2.2.1) (2026-04-09)
|
|
4
13
|
|
|
5
14
|
|
|
@@ -23,8 +23,8 @@ defaults.toml → bundled task definitions (11 tasks), loaded via importlib.reso
|
|
|
23
23
|
config.py → TaskDef dataclass, load_task_defs(), resolve_variables(), get_brew_prefix(),
|
|
24
24
|
Config.load() (3-layer merge: defaults.toml → user config → env vars)
|
|
25
25
|
tasks.py → _build_cmd(), run_task(), _run(), run_all_tasks() data-driven loop,
|
|
26
|
-
frequency scheduling, ANSI stripping
|
|
27
|
-
cli.py → Typer app: run, tasks, init, show-config, setup, status, logs, notify-test
|
|
26
|
+
frequency scheduling, format_last_run(), format_next_run(), ANSI stripping
|
|
27
|
+
cli.py → Typer app: run, tasks, init, show-config, setup, status (dashboard), logs, notify-test
|
|
28
28
|
output.py → TaskResult dataclass, Rich Live table TUI (interactive), Python logging (non-interactive)
|
|
29
29
|
notify.py → macOS notifications via terminal-notifier (preferred) / osascript (fallback)
|
|
30
30
|
```
|
|
@@ -64,6 +64,8 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
|
|
|
64
64
|
- Thresholds are 6 days for weekly and 27 days for monthly (not 7/30 — buffer for launchd schedule drift after sleep/reboot). State tracked in `~/.local/state/mac-upkeep/last-run.json`.
|
|
65
65
|
- **Safety net**: prevents redundant runs from RunAtLoad boot triggers, launchd coalescing, and manual `mac-upkeep run`. `run_at_load true` is intentional — `StartCalendarInterval` does NOT coalesce from power-off (only sleep), so RunAtLoad is essential for laptops that reboot frequently.
|
|
66
66
|
- Timestamps only update on successful non-dry-run execution. Corrupt/missing state file silently triggers re-run.
|
|
67
|
+
- **`FREQUENCY_THRESHOLDS` is dual-purpose**: used for gating in `_should_run()` and for display in `format_next_run()`. `format_next_run()` accepts an optional `state` dict parameter to avoid redundant `_load_state()` calls — the `tasks` command pre-loads state once; `_run()` skip path omits it (one-off read is fine).
|
|
68
|
+
- **Status column priority in `tasks` command**: `disabled → not found → ready` mirrors the check order in `run_task()` (disabled check then detection check) but is computed independently in `cli.py` using `td.enabled` and `shutil.which(td.detect)`. `td.detect` is already variable-resolved and auto-inferred by `Config.load()`, so `shutil.which(td.detect)` works directly — no raw TOML variable resolution needed.
|
|
67
69
|
|
|
68
70
|
### Output and notifications
|
|
69
71
|
|
|
@@ -73,6 +75,7 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
|
|
|
73
75
|
- **terminal-notifier preferred**: `shutil.which("terminal-notifier")` tries the richer tool first. Fallback to osascript loses `-group` (dedup), `-activate` (focus terminal), `-open` (click action).
|
|
74
76
|
- **Bundle ID detection chain**: `CMUX_BUNDLE_ID` env var → Ghostty.app plist via `defaults read` → `com.apple.Terminal` fallback.
|
|
75
77
|
- **Rich is a transitive dependency**: `typer>=0.12` requires `rich>=12.3.0`. Using Rich adds zero new runtime dependencies.
|
|
78
|
+
- **`status` dashboard graceful degradation**: if `_get_service_info()` returns None (brew not installed, service not registered, or JSON parse failure), the service header is skipped and only the task scheduling summary is shown. Reuses `format_last_run()` and `format_next_run()` from tasks.py. Test by patching `mac_upkeep.cli._get_service_info` directly rather than mocking subprocess.run (avoids interfering with Config.load() → get_brew_prefix() subprocess call).
|
|
76
79
|
|
|
77
80
|
### sudo + HOME
|
|
78
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mac-upkeep
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Automated macOS maintenance CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/calvindotsg/mac-upkeep
|
|
6
6
|
Project-URL: Repository, https://github.com/calvindotsg/mac-upkeep
|
|
@@ -70,7 +70,7 @@ uvx mac-upkeep run # one-off without installing
|
|
|
70
70
|
Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
-
mac-upkeep tasks # See all tasks with frequency and
|
|
73
|
+
mac-upkeep tasks # See all tasks with status, frequency, and next run
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
## Usage
|
|
@@ -81,12 +81,12 @@ mac-upkeep run --dry-run # Preview without executing
|
|
|
81
81
|
mac-upkeep run --force brew_update # Run only brew_update
|
|
82
82
|
mac-upkeep run --force all # Run all, ignoring schedule
|
|
83
83
|
mac-upkeep run --debug # Verbose output
|
|
84
|
-
mac-upkeep tasks # List tasks with status
|
|
84
|
+
mac-upkeep tasks # List tasks with status and next run
|
|
85
85
|
mac-upkeep init # Generate config (detects your tools)
|
|
86
86
|
mac-upkeep show-config --default # Show all available task options
|
|
87
87
|
mac-upkeep show-config # Show your config overrides
|
|
88
88
|
mac-upkeep setup # Print sudoers rules
|
|
89
|
-
mac-upkeep status # Show
|
|
89
|
+
mac-upkeep status # Show scheduling dashboard
|
|
90
90
|
mac-upkeep logs # View last 20 log lines
|
|
91
91
|
mac-upkeep logs -f # Follow logs
|
|
92
92
|
mac-upkeep --version # Show version
|
|
@@ -43,7 +43,7 @@ uvx mac-upkeep run # one-off without installing
|
|
|
43
43
|
Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
mac-upkeep tasks # See all tasks with frequency and
|
|
46
|
+
mac-upkeep tasks # See all tasks with status, frequency, and next run
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
## Usage
|
|
@@ -54,12 +54,12 @@ mac-upkeep run --dry-run # Preview without executing
|
|
|
54
54
|
mac-upkeep run --force brew_update # Run only brew_update
|
|
55
55
|
mac-upkeep run --force all # Run all, ignoring schedule
|
|
56
56
|
mac-upkeep run --debug # Verbose output
|
|
57
|
-
mac-upkeep tasks # List tasks with status
|
|
57
|
+
mac-upkeep tasks # List tasks with status and next run
|
|
58
58
|
mac-upkeep init # Generate config (detects your tools)
|
|
59
59
|
mac-upkeep show-config --default # Show all available task options
|
|
60
60
|
mac-upkeep show-config # Show your config overrides
|
|
61
61
|
mac-upkeep setup # Print sudoers rules
|
|
62
|
-
mac-upkeep status # Show
|
|
62
|
+
mac-upkeep status # Show scheduling dashboard
|
|
63
63
|
mac-upkeep logs # View last 20 log lines
|
|
64
64
|
mac-upkeep logs -f # Follow logs
|
|
65
65
|
mac-upkeep --version # Show version
|
|
@@ -22,7 +22,7 @@ Adjust versions/paths:
|
|
|
22
22
|
- terminal-notifier with osascript fallback for any macOS launchd service needing actionable notifications
|
|
23
23
|
- `repository_dispatch` + GitHub App for cross-repo automation
|
|
24
24
|
- `subprocess.run(stdin=subprocess.DEVNULL)` for any CLI orchestrator wrapping interactive tools
|
|
25
|
-
- Per-task frequency scheduling with XDG state file + threshold buffers for any periodic CLI tool
|
|
25
|
+
- Per-task frequency scheduling with XDG state file + threshold buffers + humanized next-run/last-run formatting for any periodic CLI tool
|
|
26
26
|
- `RunAtLoad true` + application-level frequency thresholds for reliable launchd scheduling on laptops — `StartCalendarInterval` does NOT coalesce from power-off (only sleep), so RunAtLoad is the reliable trigger with thresholds preventing over-running
|
|
27
27
|
- Notification suppression when all tasks skip (`has_activity` guard) — for any RunAtLoad service that would otherwise notify on every boot
|
|
28
28
|
- newsyslog.d config generation via setup command for any macOS launchd service needing log rotation
|
|
@@ -20,7 +20,7 @@ updates, mole system optimization, and Brewfile enforcement.
|
|
|
20
20
|
- TOML-driven task registry — add tasks without code changes
|
|
21
21
|
- Rich Live TUI for interactive use, plain logging for launchd
|
|
22
22
|
- macOS notifications via terminal-notifier with osascript fallback
|
|
23
|
-
- Per-task frequency scheduling (weekly/monthly) with schedule drift buffers
|
|
23
|
+
- Per-task frequency scheduling (weekly/monthly) with schedule drift buffers and next-run visibility
|
|
24
24
|
- Custom tasks in user config.toml
|
|
25
25
|
|
|
26
26
|
## Links
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import getpass
|
|
6
6
|
import importlib.resources
|
|
7
|
+
import json
|
|
7
8
|
import logging
|
|
8
9
|
import os
|
|
9
10
|
import shutil
|
|
@@ -27,7 +28,7 @@ from mac_upkeep.config import (
|
|
|
27
28
|
)
|
|
28
29
|
from mac_upkeep.notify import detect_terminal_bundle_id, format_summary, notify
|
|
29
30
|
from mac_upkeep.output import Output
|
|
30
|
-
from mac_upkeep.tasks import TASKS, _load_state, run_all_tasks
|
|
31
|
+
from mac_upkeep.tasks import TASKS, _load_state, format_last_run, format_next_run, run_all_tasks
|
|
31
32
|
|
|
32
33
|
app = typer.Typer(
|
|
33
34
|
help="Automated macOS mac-upkeep CLI.\n\n"
|
|
@@ -188,20 +189,35 @@ def tasks() -> None:
|
|
|
188
189
|
table.add_column("Task", min_width=14)
|
|
189
190
|
table.add_column("Description", min_width=20)
|
|
190
191
|
table.add_column("Frequency", min_width=8)
|
|
191
|
-
table.add_column("
|
|
192
|
+
table.add_column("Status", min_width=8)
|
|
192
193
|
table.add_column("Last Run", min_width=10)
|
|
194
|
+
table.add_column("Next Run", min_width=10)
|
|
193
195
|
|
|
194
196
|
for name, td in task_list:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
if not td.enabled:
|
|
198
|
+
status = "[dim]disabled[/dim]"
|
|
199
|
+
elif shutil.which(td.detect) is None:
|
|
200
|
+
status = "[yellow]not found[/yellow]"
|
|
201
|
+
else:
|
|
202
|
+
status = "[green]ready[/green]"
|
|
203
|
+
last_run = format_last_run(state.get(name))
|
|
204
|
+
next_run = "[dim]—[/dim]" if not td.enabled else format_next_run(name, config, state)
|
|
205
|
+
table.add_row(name, td.description, td.frequency, status, last_run, next_run)
|
|
198
206
|
|
|
199
207
|
Console(highlight=False).print(table)
|
|
200
208
|
else:
|
|
201
209
|
for name, td in task_list:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
if not td.enabled:
|
|
211
|
+
status = "disabled"
|
|
212
|
+
elif shutil.which(td.detect) is None:
|
|
213
|
+
status = "not found"
|
|
214
|
+
else:
|
|
215
|
+
status = "ready"
|
|
216
|
+
last_run = format_last_run(state.get(name))
|
|
217
|
+
next_run = "—" if not td.enabled else format_next_run(name, config, state)
|
|
218
|
+
typer.echo(
|
|
219
|
+
f"{name}\t{td.description}\t{td.frequency}\t{status}\t{last_run}\t{next_run}"
|
|
220
|
+
)
|
|
205
221
|
|
|
206
222
|
|
|
207
223
|
@app.command()
|
|
@@ -399,19 +415,159 @@ def setup() -> None:
|
|
|
399
415
|
typer.echo("# | sudo tee /etc/newsyslog.d/mac-upkeep.conf")
|
|
400
416
|
|
|
401
417
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
"""Show brew service status for mac-upkeep."""
|
|
418
|
+
def _get_service_info() -> dict | None:
|
|
419
|
+
"""Query brew services info as JSON. Returns None on failure."""
|
|
405
420
|
try:
|
|
406
421
|
result = subprocess.run(
|
|
407
|
-
["brew", "services", "info", "mac-upkeep"],
|
|
422
|
+
["brew", "services", "info", "mac-upkeep", "--json"],
|
|
408
423
|
capture_output=True,
|
|
409
424
|
text=True,
|
|
410
425
|
)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
426
|
+
if result.returncode != 0:
|
|
427
|
+
return None
|
|
428
|
+
data = json.loads(result.stdout)
|
|
429
|
+
return data[0] if data else None
|
|
430
|
+
except (FileNotFoundError, json.JSONDecodeError, IndexError, OSError):
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
_WEEKDAY_NAMES = [
|
|
435
|
+
"Sunday",
|
|
436
|
+
"Monday",
|
|
437
|
+
"Tuesday",
|
|
438
|
+
"Wednesday",
|
|
439
|
+
"Thursday",
|
|
440
|
+
"Friday",
|
|
441
|
+
"Saturday",
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _format_cron_schedule(cron: dict, loaded: bool) -> str:
|
|
446
|
+
"""Convert launchd cron dict + loaded flag to schedule string."""
|
|
447
|
+
wd = cron.get("Weekday")
|
|
448
|
+
hour = cron.get("Hour", 0)
|
|
449
|
+
minute = cron.get("Minute", 0)
|
|
450
|
+
day_name = _WEEKDAY_NAMES[wd] if wd is not None and 0 <= wd <= 6 else "?"
|
|
451
|
+
period = "AM" if hour < 12 else "PM"
|
|
452
|
+
h12 = hour % 12 or 12
|
|
453
|
+
schedule = f"Every {day_name} at {h12}:{minute:02d} {period}"
|
|
454
|
+
if loaded:
|
|
455
|
+
schedule += " + on boot"
|
|
456
|
+
return schedule
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _next_trigger_date(cron: dict) -> str:
|
|
460
|
+
"""Compute next launchd trigger date from cron weekday. Returns 'Mon Apr 14' style."""
|
|
461
|
+
from datetime import date, timedelta
|
|
462
|
+
|
|
463
|
+
launchd_wd = cron.get("Weekday")
|
|
464
|
+
if launchd_wd is None:
|
|
465
|
+
return "—"
|
|
466
|
+
# launchd: 0=Sunday, 1=Monday, ..., 6=Saturday
|
|
467
|
+
# Python weekday: 0=Monday, ..., 6=Sunday → py_wd = (launchd_wd - 1) % 7
|
|
468
|
+
py_wd = (launchd_wd - 1) % 7
|
|
469
|
+
today = date.today()
|
|
470
|
+
days_ahead = (py_wd - today.weekday()) % 7
|
|
471
|
+
next_date = today + timedelta(days=days_ahead)
|
|
472
|
+
return next_date.strftime("%a %b %-d")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@app.command()
|
|
476
|
+
def status() -> None:
|
|
477
|
+
"""Show scheduling dashboard: service state, schedule, and tasks due."""
|
|
478
|
+
try:
|
|
479
|
+
v = pkg_version("mac-upkeep")
|
|
480
|
+
except Exception:
|
|
481
|
+
v = "unknown"
|
|
482
|
+
|
|
483
|
+
config = Config.load()
|
|
484
|
+
state = _load_state()
|
|
485
|
+
svc = _get_service_info()
|
|
486
|
+
|
|
487
|
+
task_list = [
|
|
488
|
+
(name, config.task_defs[name]) for name in config.run_order if name in config.task_defs
|
|
489
|
+
]
|
|
490
|
+
total = len(task_list)
|
|
491
|
+
ready_count = disabled_count = not_found_count = 0
|
|
492
|
+
overdue: list = []
|
|
493
|
+
due_soon: list = []
|
|
494
|
+
|
|
495
|
+
for name, td in task_list:
|
|
496
|
+
if not td.enabled:
|
|
497
|
+
disabled_count += 1
|
|
498
|
+
continue
|
|
499
|
+
if shutil.which(td.detect) is None:
|
|
500
|
+
not_found_count += 1
|
|
501
|
+
continue
|
|
502
|
+
ready_count += 1
|
|
503
|
+
next_str = format_next_run(name, config, state)
|
|
504
|
+
last_str = format_last_run(state.get(name))
|
|
505
|
+
if next_str == "now":
|
|
506
|
+
overdue.append((name, td, last_str, next_str))
|
|
507
|
+
elif next_str in ("in 1 day", "in 2 days"):
|
|
508
|
+
due_soon.append((name, td, last_str, next_str))
|
|
509
|
+
|
|
510
|
+
tasks_needing_attention = overdue + due_soon
|
|
511
|
+
|
|
512
|
+
summary_parts = [f"{total} tasks", f"{ready_count} ready"]
|
|
513
|
+
if disabled_count:
|
|
514
|
+
summary_parts.append(f"{disabled_count} disabled")
|
|
515
|
+
if not_found_count:
|
|
516
|
+
summary_parts.append(f"{not_found_count} not found")
|
|
517
|
+
if overdue:
|
|
518
|
+
summary_parts.append(f"{len(overdue)} overdue")
|
|
519
|
+
summary_line = ", ".join(summary_parts)
|
|
520
|
+
|
|
521
|
+
if sys.stdout.isatty():
|
|
522
|
+
from rich.console import Console
|
|
523
|
+
|
|
524
|
+
console = Console(highlight=False)
|
|
525
|
+
console.print(f"[bold]mac-upkeep v{v}[/bold]")
|
|
526
|
+
console.print()
|
|
527
|
+
if svc:
|
|
528
|
+
svc_status = svc.get("status", "unknown")
|
|
529
|
+
exit_code = svc.get("exit_code", "?")
|
|
530
|
+
cron = svc.get("cron")
|
|
531
|
+
loaded = svc.get("loaded", False)
|
|
532
|
+
exit_str = f"{exit_code} (success)" if exit_code == 0 else str(exit_code)
|
|
533
|
+
console.print(f" [dim]Service [/dim] {svc_status}")
|
|
534
|
+
if cron:
|
|
535
|
+
console.print(f" [dim]Schedule [/dim] {_format_cron_schedule(cron, loaded)}")
|
|
536
|
+
console.print(f" [dim]Last exit[/dim] {exit_str}")
|
|
537
|
+
console.print()
|
|
538
|
+
if tasks_needing_attention:
|
|
539
|
+
console.print(" [bold]Tasks due:[/bold]")
|
|
540
|
+
for name, td, last_str, next_str in tasks_needing_attention:
|
|
541
|
+
if next_str == "now":
|
|
542
|
+
next_display = "[red]⚠ overdue[/red]"
|
|
543
|
+
else:
|
|
544
|
+
next_display = f"[yellow]{next_str}[/yellow]"
|
|
545
|
+
console.print(
|
|
546
|
+
f" {name:<18} {td.frequency:<8} last: {last_str:<16} {next_display}"
|
|
547
|
+
)
|
|
548
|
+
console.print()
|
|
549
|
+
console.print(f" {summary_line}")
|
|
550
|
+
else:
|
|
551
|
+
cron = svc.get("cron") if svc else None
|
|
552
|
+
if cron:
|
|
553
|
+
next_trigger = _next_trigger_date(cron)
|
|
554
|
+
console.print(
|
|
555
|
+
f" [green]{summary_line} up to date[/green], next run {next_trigger}"
|
|
556
|
+
)
|
|
557
|
+
else:
|
|
558
|
+
console.print(f" [green]{summary_line} up to date[/green]")
|
|
559
|
+
else:
|
|
560
|
+
header_parts = [f"mac-upkeep v{v}"]
|
|
561
|
+
if svc:
|
|
562
|
+
header_parts.append(svc.get("status", "unknown"))
|
|
563
|
+
exit_code = svc.get("exit_code", "?")
|
|
564
|
+
header_parts.append(f"exit: {exit_code}")
|
|
565
|
+
cron = svc.get("cron")
|
|
566
|
+
if cron:
|
|
567
|
+
next_trigger = _next_trigger_date(cron)
|
|
568
|
+
header_parts.append(f"next: {next_trigger}")
|
|
569
|
+
typer.echo(" | ".join(header_parts))
|
|
570
|
+
typer.echo(summary_line)
|
|
415
571
|
|
|
416
572
|
|
|
417
573
|
@app.command()
|
|
@@ -70,6 +70,43 @@ def _update_last_run(task_key: str) -> None:
|
|
|
70
70
|
_save_state(state)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def format_last_run(last_run_str: str | None) -> str:
|
|
74
|
+
"""Humanize a last-run ISO timestamp: 'today', '1 day ago', 'N days ago', or 'never'."""
|
|
75
|
+
if not last_run_str:
|
|
76
|
+
return "never"
|
|
77
|
+
try:
|
|
78
|
+
last_run = datetime.fromisoformat(last_run_str)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return "never"
|
|
81
|
+
days = (datetime.now() - last_run).days
|
|
82
|
+
if days == 0:
|
|
83
|
+
return "today"
|
|
84
|
+
if days == 1:
|
|
85
|
+
return "1 day ago"
|
|
86
|
+
return f"{days} days ago"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_next_run(task_key: str, config: Config, state: dict[str, str] | None = None) -> str:
|
|
90
|
+
"""Return relative time until task is next eligible: 'now', 'in 1 day', 'in N days'."""
|
|
91
|
+
if state is None:
|
|
92
|
+
state = _load_state()
|
|
93
|
+
last_run_str = state.get(task_key)
|
|
94
|
+
if not last_run_str:
|
|
95
|
+
return "now"
|
|
96
|
+
try:
|
|
97
|
+
last_run = datetime.fromisoformat(last_run_str)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return "now"
|
|
100
|
+
frequency = config.get_frequency(task_key)
|
|
101
|
+
threshold = FREQUENCY_THRESHOLDS.get(frequency, 6)
|
|
102
|
+
remaining = threshold - (datetime.now() - last_run).days
|
|
103
|
+
if remaining <= 0:
|
|
104
|
+
return "now"
|
|
105
|
+
if remaining == 1:
|
|
106
|
+
return "in 1 day"
|
|
107
|
+
return f"in {remaining} days"
|
|
108
|
+
|
|
109
|
+
|
|
73
110
|
def strip_ansi(text: str) -> str:
|
|
74
111
|
"""Remove ANSI color codes from text."""
|
|
75
112
|
return ANSI_PATTERN.sub("", text)
|
|
@@ -175,7 +212,8 @@ def _run(
|
|
|
175
212
|
|
|
176
213
|
# Frequency: skip if ran recently (no --force = frequency applies)
|
|
177
214
|
if not dry_run and force_tasks is None and not _should_run(task_key, config):
|
|
178
|
-
|
|
215
|
+
next_str = format_next_run(task_key, config)
|
|
216
|
+
result = TaskResult(name, "skipped", reason=f"ran recently, next {next_str}")
|
|
179
217
|
output.task_done(result)
|
|
180
218
|
return result
|
|
181
219
|
|
|
@@ -50,6 +50,19 @@ def test_tasks_command():
|
|
|
50
50
|
assert result.exit_code == 0
|
|
51
51
|
for name in ["brew_update", "gcloud", "mo_clean", "brew_bundle"]:
|
|
52
52
|
assert name in result.output
|
|
53
|
+
assert "ready" in result.output
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_tasks_shows_not_found_status(tmp_path):
|
|
57
|
+
config_path = tmp_path / "config.toml"
|
|
58
|
+
with (
|
|
59
|
+
patch("mac_upkeep.cli.DEFAULT_CONFIG_PATH", config_path),
|
|
60
|
+
patch("mac_upkeep.config.get_brew_prefix", return_value="/opt/homebrew"),
|
|
61
|
+
patch("mac_upkeep.cli.shutil.which", return_value=None),
|
|
62
|
+
):
|
|
63
|
+
result = runner.invoke(app, ["tasks"])
|
|
64
|
+
assert result.exit_code == 0
|
|
65
|
+
assert "not found" in result.output
|
|
53
66
|
|
|
54
67
|
|
|
55
68
|
def test_force_invalid_shows_valid_tasks():
|
|
@@ -207,6 +220,51 @@ def test_run_no_notification_when_all_skipped(tmp_path, monkeypatch):
|
|
|
207
220
|
mock_notify.assert_not_called()
|
|
208
221
|
|
|
209
222
|
|
|
223
|
+
# --- status command ---
|
|
224
|
+
|
|
225
|
+
_MOCK_SVC_INFO = {
|
|
226
|
+
"name": "mac-upkeep",
|
|
227
|
+
"running": False,
|
|
228
|
+
"loaded": True,
|
|
229
|
+
"exit_code": 0,
|
|
230
|
+
"status": "scheduled",
|
|
231
|
+
"cron": {"Month": "*", "Day": "*", "Weekday": 1, "Hour": 12, "Minute": 0},
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_status_shows_service_info():
|
|
236
|
+
"""Status shows service state when brew services is available."""
|
|
237
|
+
with patch("mac_upkeep.cli._get_service_info", return_value=_MOCK_SVC_INFO):
|
|
238
|
+
result = runner.invoke(app, ["status"])
|
|
239
|
+
assert result.exit_code == 0
|
|
240
|
+
assert "mac-upkeep v" in result.output
|
|
241
|
+
assert "scheduled" in result.output
|
|
242
|
+
assert "exit: 0" in result.output
|
|
243
|
+
assert "tasks" in result.output
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_status_graceful_degradation():
|
|
247
|
+
"""Status shows task summary even when brew services is unavailable."""
|
|
248
|
+
with patch("mac_upkeep.cli._get_service_info", return_value=None):
|
|
249
|
+
result = runner.invoke(app, ["status"])
|
|
250
|
+
assert result.exit_code == 0
|
|
251
|
+
assert "mac-upkeep v" in result.output
|
|
252
|
+
assert "tasks" in result.output
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_status_overdue_tasks(tmp_path, monkeypatch):
|
|
256
|
+
"""Tasks that have never run appear as overdue in summary."""
|
|
257
|
+
state_file = tmp_path / "last-run.json"
|
|
258
|
+
state_file.write_text("{}") # empty state — all tasks eligible
|
|
259
|
+
monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", state_file)
|
|
260
|
+
|
|
261
|
+
with patch("mac_upkeep.cli._get_service_info", return_value=None):
|
|
262
|
+
result = runner.invoke(app, ["status"])
|
|
263
|
+
|
|
264
|
+
assert result.exit_code == 0
|
|
265
|
+
assert "overdue" in result.output
|
|
266
|
+
|
|
267
|
+
|
|
210
268
|
def test_run_sends_notification_on_activity(tmp_path, monkeypatch):
|
|
211
269
|
"""Scheduled run: at least one task ran → notification sent."""
|
|
212
270
|
state_file = tmp_path / "last-run.json"
|
|
@@ -14,6 +14,8 @@ from mac_upkeep.tasks import (
|
|
|
14
14
|
_run,
|
|
15
15
|
_should_run,
|
|
16
16
|
_update_last_run,
|
|
17
|
+
format_last_run,
|
|
18
|
+
format_next_run,
|
|
17
19
|
run_all_tasks,
|
|
18
20
|
run_task,
|
|
19
21
|
strip_ansi,
|
|
@@ -246,7 +248,7 @@ def test_run_frequency_skip(tmp_path, monkeypatch):
|
|
|
246
248
|
detect="gcloud",
|
|
247
249
|
)
|
|
248
250
|
assert result.status == "skipped"
|
|
249
|
-
assert result.reason
|
|
251
|
+
assert result.reason.startswith("ran recently, next")
|
|
250
252
|
|
|
251
253
|
|
|
252
254
|
@patch("mac_upkeep.tasks.subprocess.run")
|
|
@@ -365,3 +367,68 @@ def test_failed_task_no_timestamp_update(mock_which, mock_run, tmp_path, monkeyp
|
|
|
365
367
|
)
|
|
366
368
|
assert result.status == "failed"
|
|
367
369
|
assert not state_file.exists()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# --- format_last_run ---
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_format_last_run_none():
|
|
376
|
+
assert format_last_run(None) == "never"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_format_last_run_corrupt():
|
|
380
|
+
assert format_last_run("not-a-date") == "never"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_format_last_run_today():
|
|
384
|
+
ts = datetime.now().isoformat(timespec="seconds")
|
|
385
|
+
assert format_last_run(ts) == "today"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_format_last_run_one_day_ago():
|
|
389
|
+
ts = (datetime.now() - timedelta(days=1)).isoformat(timespec="seconds")
|
|
390
|
+
assert format_last_run(ts) == "1 day ago"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_format_last_run_many_days_ago():
|
|
394
|
+
ts = (datetime.now() - timedelta(days=5)).isoformat(timespec="seconds")
|
|
395
|
+
assert format_last_run(ts) == "5 days ago"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# --- format_next_run ---
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_format_next_run_never_ran(tmp_path, monkeypatch):
|
|
402
|
+
"""Never ran → immediately eligible → 'now'."""
|
|
403
|
+
monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", tmp_path / "last-run.json")
|
|
404
|
+
config = Config.load()
|
|
405
|
+
assert format_next_run("gcloud", config) == "now"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_format_next_run_within_threshold(tmp_path, monkeypatch):
|
|
409
|
+
"""gcloud is monthly (27-day threshold), ran 1 day ago → 26 days remaining."""
|
|
410
|
+
state_file = tmp_path / "last-run.json"
|
|
411
|
+
recent = (datetime.now() - timedelta(days=1)).isoformat(timespec="seconds")
|
|
412
|
+
state_file.write_text(json.dumps({"gcloud": recent}))
|
|
413
|
+
monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", state_file)
|
|
414
|
+
config = Config.load()
|
|
415
|
+
assert format_next_run("gcloud", config) == "in 26 days"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_format_next_run_past_threshold(tmp_path, monkeypatch):
|
|
419
|
+
"""gcloud ran 30 days ago (past 27-day threshold) → 'now'."""
|
|
420
|
+
state_file = tmp_path / "last-run.json"
|
|
421
|
+
old = (datetime.now() - timedelta(days=30)).isoformat(timespec="seconds")
|
|
422
|
+
state_file.write_text(json.dumps({"gcloud": old}))
|
|
423
|
+
monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", state_file)
|
|
424
|
+
config = Config.load()
|
|
425
|
+
assert format_next_run("gcloud", config) == "now"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_format_next_run_with_state_param(tmp_path, monkeypatch):
|
|
429
|
+
"""Pre-loaded state dict bypasses file read and produces same result."""
|
|
430
|
+
monkeypatch.setattr("mac_upkeep.tasks._STATE_FILE", tmp_path / "last-run.json")
|
|
431
|
+
config = Config.load()
|
|
432
|
+
recent = (datetime.now() - timedelta(days=1)).isoformat(timespec="seconds")
|
|
433
|
+
state = {"gcloud": recent}
|
|
434
|
+
assert format_next_run("gcloud", config, state) == "in 26 days"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|