mac-upkeep 2.2.1__tar.gz → 2.4.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.4.0/.release-please-manifest.json +3 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/CHANGELOG.md +16 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/CLAUDE.md +13 -4
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/PKG-INFO +31 -5
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/README.md +30 -4
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/docs/reusable-patterns.md +2 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/llms.txt +3 -3
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/pyproject.toml +1 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/cli.py +181 -17
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/config.py +22 -4
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/defaults.toml +7 -0
- mac_upkeep-2.4.0/src/mac_upkeep/git_sync.py +138 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/tasks.py +146 -5
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_cli.py +60 -1
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_config.py +83 -13
- mac_upkeep-2.4.0/tests/test_git_sync.py +279 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_tasks.py +272 -2
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/uv.lock +1 -1
- mac_upkeep-2.2.1/.release-please-manifest.json +0 -3
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/.github/workflows/release.yml +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/.github/workflows/test.yml +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/.gitignore +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/CONTRIBUTING.md +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/LICENSE +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/demo/demo.gif +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/demo/record.sh +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/release-please-config.json +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/__init__.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/notify.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/output.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/py.typed +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/__init__.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_notify.py +0 -0
- {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_output.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.3.0...v2.4.0) (2026-04-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add git_sync task with daily frequency and handler dispatch ([#35](https://github.com/calvindotsg/mac-upkeep/issues/35)) ([5f4628f](https://github.com/calvindotsg/mac-upkeep/commit/5f4628f5eaae9ddc6941fff72c2eb4cd841390ee))
|
|
9
|
+
|
|
10
|
+
## [2.3.0](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.1...v2.3.0) (2026-04-13)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add next scheduled run visibility to tasks and run ([6317ce1](https://github.com/calvindotsg/mac-upkeep/commit/6317ce18c041a8f29a8e8534a061bfd3aec24343))
|
|
16
|
+
* add next scheduled run visibility to tasks and run ([34dbe93](https://github.com/calvindotsg/mac-upkeep/commit/34dbe93fab3781d7ce669c852b6c06eb0ab8d5ca))
|
|
17
|
+
* redesign status command as scheduling dashboard ([#34](https://github.com/calvindotsg/mac-upkeep/issues/34)) ([86ca27c](https://github.com/calvindotsg/mac-upkeep/commit/86ca27cb7f7b1fd8878996552246061ebdd6d598))
|
|
18
|
+
|
|
3
19
|
## [2.2.1](https://github.com/calvindotsg/mac-upkeep/compare/v2.2.0...v2.2.1) (2026-04-09)
|
|
4
20
|
|
|
5
21
|
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
## Architecture
|
|
20
20
|
|
|
21
21
|
```
|
|
22
|
-
defaults.toml → bundled task definitions
|
|
22
|
+
defaults.toml → bundled task definitions, loaded via importlib.resources
|
|
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
|
```
|
|
@@ -61,9 +61,11 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
|
|
|
61
61
|
|
|
62
62
|
### Frequency scheduling
|
|
63
63
|
|
|
64
|
-
- Thresholds are 6 days for weekly
|
|
64
|
+
- Thresholds are 20 hours for daily, 6 days for weekly, 27 days for monthly (not 24h/7d/30d — 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,11 @@ 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).
|
|
79
|
+
|
|
80
|
+
### Handler-dispatched tasks
|
|
81
|
+
|
|
82
|
+
A TaskDef with `handler="<name>"` and empty `command` bypasses subprocess building: `run_all_tasks` routes it through `_run_handler`, which applies filter + frequency + `detect` gates then calls `HANDLERS[name](config, output, dry_run)`. Handler modules register themselves in `tasks._register_handlers()` (called at import). `KNOWN_HANDLERS` drives config validation — unknown handler names are rejected early. Handlers emit per-step output via `output.task_debug()` and return one aggregate `TaskResult`.
|
|
76
83
|
|
|
77
84
|
### sudo + HOME
|
|
78
85
|
|
|
@@ -95,6 +102,8 @@ Do not add conditions to the filter or remove the `force_tasks is None` guard fr
|
|
|
95
102
|
- **`terminal-notifier` is optional** — installed via `brew install terminal-notifier`.
|
|
96
103
|
- **Do not open terminal windows from launchd** — fragile (focus stealing, macOS 13+ permission escalation). Use headless + notification + click-to-act.
|
|
97
104
|
- **Testing**: `Config.load()` calls `get_brew_prefix()` which runs `subprocess.run(["brew", "--prefix"])`. Tests that mock `subprocess.run` or `shutil.which` will capture this call too (shared module objects). Mock `mac_upkeep.config.get_brew_prefix` directly in `init` command tests.
|
|
105
|
+
- **`git_sync` SSH auth under launchd** relies on `~/.ssh/config` `IdentityAgent` (path-based, e.g. 1Password socket). `SSH_AUTH_SOCK` env vars are NOT inherited by LaunchAgents, so env-based agent forwarding won't work here — the `IdentityAgent` directive is the supported path.
|
|
106
|
+
- **`git_sync` forces `GIT_TERMINAL_PROMPT=0` and defaults `GIT_ASKPASS=/usr/bin/true`** (user-set `GIT_ASKPASS` is respected) — fail-fast on auth misconfiguration instead of stalling to the 60 s subprocess timeout.
|
|
98
107
|
|
|
99
108
|
## Release Process
|
|
100
109
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mac-upkeep
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.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
|
|
@@ -66,11 +66,12 @@ uvx mac-upkeep run # one-off without installing
|
|
|
66
66
|
| `mo_purge` | Remove old project artifacts ([mole](https://github.com/nicehash/mole)) | Monthly |
|
|
67
67
|
| `brew_cleanup` | Remove old versions and cache files | Monthly |
|
|
68
68
|
| `brew_bundle` | Remove packages not in Brewfile | Weekly |
|
|
69
|
+
| `git_sync` | Pull configured git repositories | Daily |
|
|
69
70
|
|
|
70
71
|
Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
|
|
71
72
|
|
|
72
73
|
```bash
|
|
73
|
-
mac-upkeep tasks # See all tasks with frequency and
|
|
74
|
+
mac-upkeep tasks # See all tasks with status, frequency, and next run
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
## Usage
|
|
@@ -81,12 +82,12 @@ mac-upkeep run --dry-run # Preview without executing
|
|
|
81
82
|
mac-upkeep run --force brew_update # Run only brew_update
|
|
82
83
|
mac-upkeep run --force all # Run all, ignoring schedule
|
|
83
84
|
mac-upkeep run --debug # Verbose output
|
|
84
|
-
mac-upkeep tasks # List tasks with status
|
|
85
|
+
mac-upkeep tasks # List tasks with status and next run
|
|
85
86
|
mac-upkeep init # Generate config (detects your tools)
|
|
86
87
|
mac-upkeep show-config --default # Show all available task options
|
|
87
88
|
mac-upkeep show-config # Show your config overrides
|
|
88
89
|
mac-upkeep setup # Print sudoers rules
|
|
89
|
-
mac-upkeep status # Show
|
|
90
|
+
mac-upkeep status # Show scheduling dashboard
|
|
90
91
|
mac-upkeep logs # View last 20 log lines
|
|
91
92
|
mac-upkeep logs -f # Follow logs
|
|
92
93
|
mac-upkeep --version # Show version
|
|
@@ -117,7 +118,7 @@ mac-upkeep show-config --default
|
|
|
117
118
|
[tasks.gcloud]
|
|
118
119
|
enabled = false
|
|
119
120
|
|
|
120
|
-
# Change frequency (weekly or monthly)
|
|
121
|
+
# Change frequency (daily, weekly, or monthly)
|
|
121
122
|
[tasks.brew_update]
|
|
122
123
|
frequency = "monthly"
|
|
123
124
|
|
|
@@ -142,6 +143,31 @@ frequency = "monthly"
|
|
|
142
143
|
order = ["brew_update", "brew_upgrade", "docker_prune", "brew_cleanup", "brew_bundle"]
|
|
143
144
|
```
|
|
144
145
|
|
|
146
|
+
### git_sync
|
|
147
|
+
|
|
148
|
+
Pull configured git repositories daily with `git pull --ff-only`. Opt-in — list your repos explicitly:
|
|
149
|
+
|
|
150
|
+
```toml
|
|
151
|
+
[git_sync]
|
|
152
|
+
repos = [
|
|
153
|
+
"~/code/my-project",
|
|
154
|
+
"~/work/max-*", # glob patterns supported
|
|
155
|
+
]
|
|
156
|
+
skip_dirty = true # skip repos with uncommitted changes
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Each repo is skipped with a reason if it's not a git repo, has no remote, has no upstream branch, or (when `skip_dirty = true`) has uncommitted changes.
|
|
160
|
+
|
|
161
|
+
#### Authentication
|
|
162
|
+
|
|
163
|
+
Any of the following work under launchd without mac-upkeep-side configuration:
|
|
164
|
+
|
|
165
|
+
- **SSH + `IdentityAgent` (recommended under launchd):** a path-based entry in `~/.ssh/config` pointing at any SSH agent's UNIX socket. Works because the directive is a file path, not the `SSH_AUTH_SOCK` env var that launchd would strip.
|
|
166
|
+
- **HTTPS + credential helper:** `gh auth setup-git` or `git config --global credential.helper osxkeychain`. Requires the helper binary on the launchd `PATH`.
|
|
167
|
+
- **`[url].insteadOf` rewrite:** force SSH regardless of remote protocol by rewriting `https://<host>/` in `~/.gitconfig` to a matching SSH `Host` alias. Bypasses HTTPS auth entirely.
|
|
168
|
+
|
|
169
|
+
git_sync sets `GIT_TERMINAL_PROMPT=0` and a no-op `GIT_ASKPASS` default (user-set `GIT_ASKPASS` is respected) so misconfigured auth fails in milliseconds instead of stalling to the 60 s subprocess timeout.
|
|
170
|
+
|
|
145
171
|
### Environment variables
|
|
146
172
|
|
|
147
173
|
```bash
|
|
@@ -39,11 +39,12 @@ uvx mac-upkeep run # one-off without installing
|
|
|
39
39
|
| `mo_purge` | Remove old project artifacts ([mole](https://github.com/nicehash/mole)) | Monthly |
|
|
40
40
|
| `brew_cleanup` | Remove old versions and cache files | Monthly |
|
|
41
41
|
| `brew_bundle` | Remove packages not in Brewfile | Weekly |
|
|
42
|
+
| `git_sync` | Pull configured git repositories | Daily |
|
|
42
43
|
|
|
43
44
|
Tasks auto-detect installed tools — missing tools are skipped. Use `--force <task>` to run a specific task on demand.
|
|
44
45
|
|
|
45
46
|
```bash
|
|
46
|
-
mac-upkeep tasks # See all tasks with frequency and
|
|
47
|
+
mac-upkeep tasks # See all tasks with status, frequency, and next run
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
## Usage
|
|
@@ -54,12 +55,12 @@ mac-upkeep run --dry-run # Preview without executing
|
|
|
54
55
|
mac-upkeep run --force brew_update # Run only brew_update
|
|
55
56
|
mac-upkeep run --force all # Run all, ignoring schedule
|
|
56
57
|
mac-upkeep run --debug # Verbose output
|
|
57
|
-
mac-upkeep tasks # List tasks with status
|
|
58
|
+
mac-upkeep tasks # List tasks with status and next run
|
|
58
59
|
mac-upkeep init # Generate config (detects your tools)
|
|
59
60
|
mac-upkeep show-config --default # Show all available task options
|
|
60
61
|
mac-upkeep show-config # Show your config overrides
|
|
61
62
|
mac-upkeep setup # Print sudoers rules
|
|
62
|
-
mac-upkeep status # Show
|
|
63
|
+
mac-upkeep status # Show scheduling dashboard
|
|
63
64
|
mac-upkeep logs # View last 20 log lines
|
|
64
65
|
mac-upkeep logs -f # Follow logs
|
|
65
66
|
mac-upkeep --version # Show version
|
|
@@ -90,7 +91,7 @@ mac-upkeep show-config --default
|
|
|
90
91
|
[tasks.gcloud]
|
|
91
92
|
enabled = false
|
|
92
93
|
|
|
93
|
-
# Change frequency (weekly or monthly)
|
|
94
|
+
# Change frequency (daily, weekly, or monthly)
|
|
94
95
|
[tasks.brew_update]
|
|
95
96
|
frequency = "monthly"
|
|
96
97
|
|
|
@@ -115,6 +116,31 @@ frequency = "monthly"
|
|
|
115
116
|
order = ["brew_update", "brew_upgrade", "docker_prune", "brew_cleanup", "brew_bundle"]
|
|
116
117
|
```
|
|
117
118
|
|
|
119
|
+
### git_sync
|
|
120
|
+
|
|
121
|
+
Pull configured git repositories daily with `git pull --ff-only`. Opt-in — list your repos explicitly:
|
|
122
|
+
|
|
123
|
+
```toml
|
|
124
|
+
[git_sync]
|
|
125
|
+
repos = [
|
|
126
|
+
"~/code/my-project",
|
|
127
|
+
"~/work/max-*", # glob patterns supported
|
|
128
|
+
]
|
|
129
|
+
skip_dirty = true # skip repos with uncommitted changes
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Each repo is skipped with a reason if it's not a git repo, has no remote, has no upstream branch, or (when `skip_dirty = true`) has uncommitted changes.
|
|
133
|
+
|
|
134
|
+
#### Authentication
|
|
135
|
+
|
|
136
|
+
Any of the following work under launchd without mac-upkeep-side configuration:
|
|
137
|
+
|
|
138
|
+
- **SSH + `IdentityAgent` (recommended under launchd):** a path-based entry in `~/.ssh/config` pointing at any SSH agent's UNIX socket. Works because the directive is a file path, not the `SSH_AUTH_SOCK` env var that launchd would strip.
|
|
139
|
+
- **HTTPS + credential helper:** `gh auth setup-git` or `git config --global credential.helper osxkeychain`. Requires the helper binary on the launchd `PATH`.
|
|
140
|
+
- **`[url].insteadOf` rewrite:** force SSH regardless of remote protocol by rewriting `https://<host>/` in `~/.gitconfig` to a matching SSH `Host` alias. Bypasses HTTPS auth entirely.
|
|
141
|
+
|
|
142
|
+
git_sync sets `GIT_TERMINAL_PROMPT=0` and a no-op `GIT_ASKPASS` default (user-set `GIT_ASKPASS` is respected) so misconfigured auth fails in milliseconds instead of stalling to the 60 s subprocess timeout.
|
|
143
|
+
|
|
118
144
|
### Environment variables
|
|
119
145
|
|
|
120
146
|
```bash
|
|
@@ -15,6 +15,7 @@ Adjust versions/paths:
|
|
|
15
15
|
## Adapt
|
|
16
16
|
|
|
17
17
|
- TOML-driven task definitions with `importlib.resources` bundling — for any CLI needing an extensible command registry where adding a task shouldn't require code changes
|
|
18
|
+
- Handler registry dispatch (`HANDLERS: dict[str, Callable]` + `_run_handler`) — for any TOML-driven CLI that needs some tasks to run as Python rather than subprocess commands, without coupling the framework to specific handler implementations
|
|
18
19
|
- `init` with system detection via `shutil.which()` — for any CLI replacing static example config files with generated, system-aware configs
|
|
19
20
|
- 3-layer config merge (bundled `defaults.toml` → user `config.toml` → env vars) with field-level override — for any CLI needing layered configuration
|
|
20
21
|
- `${VAR}` variable resolution in TOML fields — for portable paths across architectures (`${BREW_PREFIX}` resolves differently on Apple Silicon vs Intel)
|
|
@@ -22,7 +23,7 @@ Adjust versions/paths:
|
|
|
22
23
|
- terminal-notifier with osascript fallback for any macOS launchd service needing actionable notifications
|
|
23
24
|
- `repository_dispatch` + GitHub App for cross-repo automation
|
|
24
25
|
- `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
|
|
26
|
+
- Per-task frequency scheduling with XDG state file + threshold buffers + humanized next-run/last-run formatting for any periodic CLI tool
|
|
26
27
|
- `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
28
|
- Notification suppression when all tasks skip (`has_activity` guard) — for any RunAtLoad service that would otherwise notify on every boot
|
|
28
29
|
- newsyslog.d config generation via setup command for any macOS launchd service needing log rotation
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
Runs
|
|
7
|
+
Runs maintenance tasks on boot + recurring schedule via brew services:
|
|
8
8
|
Homebrew update/upgrade/cleanup, gcloud/pnpm/uv cache pruning, Fish plugin
|
|
9
|
-
updates, mole system optimization, and
|
|
9
|
+
updates, mole system optimization, Brewfile enforcement, and git repository sync.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -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 (daily/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"
|
|
@@ -126,7 +127,7 @@ def run(
|
|
|
126
127
|
Disable specific tasks via config file or MAC_UPKEEP_<TASK>=false environment variables.
|
|
127
128
|
|
|
128
129
|
Task order: brew_update, brew_upgrade, gcloud, pnpm, uv, fisher,
|
|
129
|
-
mo_clean, mo_optimize, mo_purge, brew_cleanup, brew_bundle.
|
|
130
|
+
mo_clean, mo_optimize, mo_purge, brew_cleanup, brew_bundle, git_sync.
|
|
130
131
|
brew_cleanup runs after mo_clean (which runs brew autoremove).
|
|
131
132
|
brew_bundle runs last (homebrew/brew#21350).
|
|
132
133
|
|
|
@@ -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()
|
|
@@ -303,6 +319,14 @@ def _generate_init_config(
|
|
|
303
319
|
lines.append('# detect = "docker"')
|
|
304
320
|
lines.append('# frequency = "monthly"')
|
|
305
321
|
lines.append("#")
|
|
322
|
+
lines.append("# git_sync pulls configured repos daily (fast-forward only, skips dirty)")
|
|
323
|
+
lines.append("# [git_sync]")
|
|
324
|
+
lines.append("# repos = [")
|
|
325
|
+
lines.append('# "~/Documents/github/org/repo",')
|
|
326
|
+
lines.append('# "~/Documents/github/org/other-*", # glob supported')
|
|
327
|
+
lines.append("# ]")
|
|
328
|
+
lines.append("# skip_dirty = true")
|
|
329
|
+
lines.append("#")
|
|
306
330
|
if detected:
|
|
307
331
|
order_str = str([t for t, _ in detected]).replace("'", '"')
|
|
308
332
|
lines.append("# [run]")
|
|
@@ -399,19 +423,159 @@ def setup() -> None:
|
|
|
399
423
|
typer.echo("# | sudo tee /etc/newsyslog.d/mac-upkeep.conf")
|
|
400
424
|
|
|
401
425
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
"""Show brew service status for mac-upkeep."""
|
|
426
|
+
def _get_service_info() -> dict | None:
|
|
427
|
+
"""Query brew services info as JSON. Returns None on failure."""
|
|
405
428
|
try:
|
|
406
429
|
result = subprocess.run(
|
|
407
|
-
["brew", "services", "info", "mac-upkeep"],
|
|
430
|
+
["brew", "services", "info", "mac-upkeep", "--json"],
|
|
408
431
|
capture_output=True,
|
|
409
432
|
text=True,
|
|
410
433
|
)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
434
|
+
if result.returncode != 0:
|
|
435
|
+
return None
|
|
436
|
+
data = json.loads(result.stdout)
|
|
437
|
+
return data[0] if data else None
|
|
438
|
+
except (FileNotFoundError, json.JSONDecodeError, IndexError, OSError):
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
_WEEKDAY_NAMES = [
|
|
443
|
+
"Sunday",
|
|
444
|
+
"Monday",
|
|
445
|
+
"Tuesday",
|
|
446
|
+
"Wednesday",
|
|
447
|
+
"Thursday",
|
|
448
|
+
"Friday",
|
|
449
|
+
"Saturday",
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _format_cron_schedule(cron: dict, loaded: bool) -> str:
|
|
454
|
+
"""Convert launchd cron dict + loaded flag to schedule string."""
|
|
455
|
+
wd = cron.get("Weekday")
|
|
456
|
+
hour = cron.get("Hour", 0)
|
|
457
|
+
minute = cron.get("Minute", 0)
|
|
458
|
+
day_name = _WEEKDAY_NAMES[wd] if wd is not None and 0 <= wd <= 6 else "?"
|
|
459
|
+
period = "AM" if hour < 12 else "PM"
|
|
460
|
+
h12 = hour % 12 or 12
|
|
461
|
+
schedule = f"Every {day_name} at {h12}:{minute:02d} {period}"
|
|
462
|
+
if loaded:
|
|
463
|
+
schedule += " + on boot"
|
|
464
|
+
return schedule
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _next_trigger_date(cron: dict) -> str:
|
|
468
|
+
"""Compute next launchd trigger date from cron weekday. Returns 'Mon Apr 14' style."""
|
|
469
|
+
from datetime import date, timedelta
|
|
470
|
+
|
|
471
|
+
launchd_wd = cron.get("Weekday")
|
|
472
|
+
if launchd_wd is None:
|
|
473
|
+
return "—"
|
|
474
|
+
# launchd: 0=Sunday, 1=Monday, ..., 6=Saturday
|
|
475
|
+
# Python weekday: 0=Monday, ..., 6=Sunday → py_wd = (launchd_wd - 1) % 7
|
|
476
|
+
py_wd = (launchd_wd - 1) % 7
|
|
477
|
+
today = date.today()
|
|
478
|
+
days_ahead = (py_wd - today.weekday()) % 7
|
|
479
|
+
next_date = today + timedelta(days=days_ahead)
|
|
480
|
+
return next_date.strftime("%a %b %-d")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@app.command()
|
|
484
|
+
def status() -> None:
|
|
485
|
+
"""Show scheduling dashboard: service state, schedule, and tasks due."""
|
|
486
|
+
try:
|
|
487
|
+
v = pkg_version("mac-upkeep")
|
|
488
|
+
except Exception:
|
|
489
|
+
v = "unknown"
|
|
490
|
+
|
|
491
|
+
config = Config.load()
|
|
492
|
+
state = _load_state()
|
|
493
|
+
svc = _get_service_info()
|
|
494
|
+
|
|
495
|
+
task_list = [
|
|
496
|
+
(name, config.task_defs[name]) for name in config.run_order if name in config.task_defs
|
|
497
|
+
]
|
|
498
|
+
total = len(task_list)
|
|
499
|
+
ready_count = disabled_count = not_found_count = 0
|
|
500
|
+
overdue: list = []
|
|
501
|
+
due_soon: list = []
|
|
502
|
+
|
|
503
|
+
for name, td in task_list:
|
|
504
|
+
if not td.enabled:
|
|
505
|
+
disabled_count += 1
|
|
506
|
+
continue
|
|
507
|
+
if shutil.which(td.detect) is None:
|
|
508
|
+
not_found_count += 1
|
|
509
|
+
continue
|
|
510
|
+
ready_count += 1
|
|
511
|
+
next_str = format_next_run(name, config, state)
|
|
512
|
+
last_str = format_last_run(state.get(name))
|
|
513
|
+
if next_str == "now":
|
|
514
|
+
overdue.append((name, td, last_str, next_str))
|
|
515
|
+
elif next_str in ("in 1 day", "in 2 days"):
|
|
516
|
+
due_soon.append((name, td, last_str, next_str))
|
|
517
|
+
|
|
518
|
+
tasks_needing_attention = overdue + due_soon
|
|
519
|
+
|
|
520
|
+
summary_parts = [f"{total} tasks", f"{ready_count} ready"]
|
|
521
|
+
if disabled_count:
|
|
522
|
+
summary_parts.append(f"{disabled_count} disabled")
|
|
523
|
+
if not_found_count:
|
|
524
|
+
summary_parts.append(f"{not_found_count} not found")
|
|
525
|
+
if overdue:
|
|
526
|
+
summary_parts.append(f"{len(overdue)} overdue")
|
|
527
|
+
summary_line = ", ".join(summary_parts)
|
|
528
|
+
|
|
529
|
+
if sys.stdout.isatty():
|
|
530
|
+
from rich.console import Console
|
|
531
|
+
|
|
532
|
+
console = Console(highlight=False)
|
|
533
|
+
console.print(f"[bold]mac-upkeep v{v}[/bold]")
|
|
534
|
+
console.print()
|
|
535
|
+
if svc:
|
|
536
|
+
svc_status = svc.get("status", "unknown")
|
|
537
|
+
exit_code = svc.get("exit_code", "?")
|
|
538
|
+
cron = svc.get("cron")
|
|
539
|
+
loaded = svc.get("loaded", False)
|
|
540
|
+
exit_str = f"{exit_code} (success)" if exit_code == 0 else str(exit_code)
|
|
541
|
+
console.print(f" [dim]Service [/dim] {svc_status}")
|
|
542
|
+
if cron:
|
|
543
|
+
console.print(f" [dim]Schedule [/dim] {_format_cron_schedule(cron, loaded)}")
|
|
544
|
+
console.print(f" [dim]Last exit[/dim] {exit_str}")
|
|
545
|
+
console.print()
|
|
546
|
+
if tasks_needing_attention:
|
|
547
|
+
console.print(" [bold]Tasks due:[/bold]")
|
|
548
|
+
for name, td, last_str, next_str in tasks_needing_attention:
|
|
549
|
+
if next_str == "now":
|
|
550
|
+
next_display = "[red]⚠ overdue[/red]"
|
|
551
|
+
else:
|
|
552
|
+
next_display = f"[yellow]{next_str}[/yellow]"
|
|
553
|
+
console.print(
|
|
554
|
+
f" {name:<18} {td.frequency:<8} last: {last_str:<16} {next_display}"
|
|
555
|
+
)
|
|
556
|
+
console.print()
|
|
557
|
+
console.print(f" {summary_line}")
|
|
558
|
+
else:
|
|
559
|
+
cron = svc.get("cron") if svc else None
|
|
560
|
+
if cron:
|
|
561
|
+
next_trigger = _next_trigger_date(cron)
|
|
562
|
+
console.print(
|
|
563
|
+
f" [green]{summary_line} up to date[/green], next run {next_trigger}"
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
console.print(f" [green]{summary_line} up to date[/green]")
|
|
567
|
+
else:
|
|
568
|
+
header_parts = [f"mac-upkeep v{v}"]
|
|
569
|
+
if svc:
|
|
570
|
+
header_parts.append(svc.get("status", "unknown"))
|
|
571
|
+
exit_code = svc.get("exit_code", "?")
|
|
572
|
+
header_parts.append(f"exit: {exit_code}")
|
|
573
|
+
cron = svc.get("cron")
|
|
574
|
+
if cron:
|
|
575
|
+
next_trigger = _next_trigger_date(cron)
|
|
576
|
+
header_parts.append(f"next: {next_trigger}")
|
|
577
|
+
typer.echo(" | ".join(header_parts))
|
|
578
|
+
typer.echo(summary_line)
|
|
415
579
|
|
|
416
580
|
|
|
417
581
|
@app.command()
|
|
@@ -31,6 +31,7 @@ class TaskDef:
|
|
|
31
31
|
shell: str = ""
|
|
32
32
|
require_file: str = ""
|
|
33
33
|
timeout: int = 300
|
|
34
|
+
handler: str = ""
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
def get_brew_prefix() -> str:
|
|
@@ -133,12 +134,20 @@ def load_task_defs(
|
|
|
133
134
|
run_order.append(name)
|
|
134
135
|
|
|
135
136
|
# Validate task definitions
|
|
137
|
+
from mac_upkeep.tasks import KNOWN_HANDLERS # local import to avoid cycle
|
|
138
|
+
|
|
136
139
|
for name, td in task_defs.items():
|
|
137
|
-
if
|
|
138
|
-
raise ValueError(f"Task '{name}'
|
|
139
|
-
if td.
|
|
140
|
+
if td.command and td.handler:
|
|
141
|
+
raise ValueError(f"Task '{name}': cannot set both 'command' and 'handler'")
|
|
142
|
+
if not td.command and not td.handler:
|
|
143
|
+
raise ValueError(f"Task '{name}' has no command or handler")
|
|
144
|
+
if td.handler and td.handler not in KNOWN_HANDLERS:
|
|
145
|
+
known = ", ".join(sorted(KNOWN_HANDLERS)) or "(none registered)"
|
|
146
|
+
raise ValueError(f"Task '{name}': unknown handler '{td.handler}' (known: {known})")
|
|
147
|
+
if td.frequency not in ("daily", "weekly", "monthly"):
|
|
140
148
|
raise ValueError(
|
|
141
|
-
f"Task '{name}': frequency must be 'weekly' or 'monthly',
|
|
149
|
+
f"Task '{name}': frequency must be 'daily', 'weekly', or 'monthly', "
|
|
150
|
+
f"got '{td.frequency}'"
|
|
142
151
|
)
|
|
143
152
|
for entry in run_order:
|
|
144
153
|
if entry not in task_defs:
|
|
@@ -185,6 +194,7 @@ def _parse_task_def(name: str, data: dict) -> TaskDef:
|
|
|
185
194
|
shell=data.get("shell", ""),
|
|
186
195
|
require_file=data.get("require_file", ""),
|
|
187
196
|
timeout=data.get("timeout", 300),
|
|
197
|
+
handler=data.get("handler", ""),
|
|
188
198
|
)
|
|
189
199
|
|
|
190
200
|
|
|
@@ -197,6 +207,8 @@ class Config:
|
|
|
197
207
|
brewfile: str | None = None
|
|
198
208
|
notify: bool = True
|
|
199
209
|
notify_sound: str = "Submarine"
|
|
210
|
+
git_sync_repos: list[str] = field(default_factory=list)
|
|
211
|
+
git_sync_skip_dirty: bool = True
|
|
200
212
|
|
|
201
213
|
@classmethod
|
|
202
214
|
def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
|
@@ -217,6 +229,12 @@ class Config:
|
|
|
217
229
|
if "sound" in notif:
|
|
218
230
|
config.notify_sound = str(notif["sound"])
|
|
219
231
|
|
|
232
|
+
# Extract git_sync settings from user config
|
|
233
|
+
if user_data and "git_sync" in user_data:
|
|
234
|
+
gs = user_data["git_sync"]
|
|
235
|
+
config.git_sync_repos = list(gs.get("repos", []))
|
|
236
|
+
config.git_sync_skip_dirty = bool(gs.get("skip_dirty", True))
|
|
237
|
+
|
|
220
238
|
# Extract brewfile from user config
|
|
221
239
|
if user_data and "paths" in user_data and "brewfile" in user_data["paths"]:
|
|
222
240
|
config.brewfile = user_data["paths"]["brewfile"]
|
|
@@ -65,6 +65,12 @@ command = "brew bundle cleanup --force --file=${BREWFILE}"
|
|
|
65
65
|
detect = "brew"
|
|
66
66
|
require_file = "${BREWFILE}"
|
|
67
67
|
|
|
68
|
+
[tasks.git_sync]
|
|
69
|
+
description = "Pull configured git repositories"
|
|
70
|
+
handler = "git_sync"
|
|
71
|
+
detect = "git"
|
|
72
|
+
frequency = "daily"
|
|
73
|
+
|
|
68
74
|
[run]
|
|
69
75
|
order = [
|
|
70
76
|
"brew_update",
|
|
@@ -78,4 +84,5 @@ order = [
|
|
|
78
84
|
"mo_purge",
|
|
79
85
|
"brew_cleanup",
|
|
80
86
|
"brew_bundle",
|
|
87
|
+
"git_sync",
|
|
81
88
|
]
|