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.
Files changed (34) hide show
  1. mac_upkeep-2.4.0/.release-please-manifest.json +3 -0
  2. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/CHANGELOG.md +16 -0
  3. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/CLAUDE.md +13 -4
  4. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/PKG-INFO +31 -5
  5. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/README.md +30 -4
  6. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/docs/reusable-patterns.md +2 -1
  7. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/llms.txt +3 -3
  8. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/pyproject.toml +1 -1
  9. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/cli.py +181 -17
  10. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/config.py +22 -4
  11. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/defaults.toml +7 -0
  12. mac_upkeep-2.4.0/src/mac_upkeep/git_sync.py +138 -0
  13. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/tasks.py +146 -5
  14. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_cli.py +60 -1
  15. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_config.py +83 -13
  16. mac_upkeep-2.4.0/tests/test_git_sync.py +279 -0
  17. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_tasks.py +272 -2
  18. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/uv.lock +1 -1
  19. mac_upkeep-2.2.1/.release-please-manifest.json +0 -3
  20. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/.github/workflows/release.yml +0 -0
  21. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/.github/workflows/test.yml +0 -0
  22. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/.gitignore +0 -0
  23. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/CONTRIBUTING.md +0 -0
  24. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/LICENSE +0 -0
  25. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/demo/demo.gif +0 -0
  26. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/demo/record.sh +0 -0
  27. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/release-please-config.json +0 -0
  28. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/__init__.py +0 -0
  29. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/notify.py +0 -0
  30. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/output.py +0 -0
  31. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/src/mac_upkeep/py.typed +0 -0
  32. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/__init__.py +0 -0
  33. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_notify.py +0 -0
  34. {mac_upkeep-2.2.1 → mac_upkeep-2.4.0}/tests/test_output.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "2.4.0"
3
+ }
@@ -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 (11 tasks), loaded via importlib.resources
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 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`.
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.2.1
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 last-run status
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 brew service status
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 last-run status
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 brew service status
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 11 maintenance tasks on boot + weekly/monthly schedule via brew services:
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 Brewfile enforcement.
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mac-upkeep"
3
- version = "2.2.1"
3
+ version = "2.4.0"
4
4
  description = "Automated macOS maintenance CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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("Enabled", min_width=7)
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
- enabled = "[green]yes[/green]" if td.enabled else "[dim]no[/dim]"
196
- last_run = state.get(name, "never")
197
- table.add_row(name, td.description, td.frequency, enabled, last_run)
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
- enabled = "yes" if td.enabled else "no"
203
- last_run = state.get(name, "never")
204
- typer.echo(f"{name}\t{td.description}\t{td.frequency}\t{enabled}\t{last_run}")
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
- @app.command()
403
- def status() -> None:
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
- typer.echo(result.stdout.strip())
412
- except FileNotFoundError:
413
- typer.echo("brew not found. Install Homebrew first.")
414
- raise typer.Exit(1)
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 not td.command:
138
- raise ValueError(f"Task '{name}' has no command")
139
- if td.frequency not in ("weekly", "monthly"):
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', got '{td.frequency}'"
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
  ]