mac-upkeep 2.1.0__py3-none-any.whl
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/__init__.py +0 -0
- mac_upkeep/cli.py +439 -0
- mac_upkeep/config.py +264 -0
- mac_upkeep/defaults.toml +81 -0
- mac_upkeep/notify.py +106 -0
- mac_upkeep/output.py +206 -0
- mac_upkeep/tasks.py +246 -0
- mac_upkeep-2.1.0.dist-info/METADATA +145 -0
- mac_upkeep-2.1.0.dist-info/RECORD +12 -0
- mac_upkeep-2.1.0.dist-info/WHEEL +4 -0
- mac_upkeep-2.1.0.dist-info/entry_points.txt +2 -0
- mac_upkeep-2.1.0.dist-info/licenses/LICENSE +21 -0
mac_upkeep/__init__.py
ADDED
|
File without changes
|
mac_upkeep/cli.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""mac-upkeep CLI — automated macOS maintenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import importlib.resources
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from importlib.metadata import version as pkg_version
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from mac_upkeep.config import (
|
|
20
|
+
DEFAULT_CONFIG_DIR,
|
|
21
|
+
DEFAULT_CONFIG_PATH,
|
|
22
|
+
Config,
|
|
23
|
+
_build_variables,
|
|
24
|
+
_load_defaults,
|
|
25
|
+
get_brew_prefix,
|
|
26
|
+
resolve_variables,
|
|
27
|
+
)
|
|
28
|
+
from mac_upkeep.notify import detect_terminal_bundle_id, format_summary, notify
|
|
29
|
+
from mac_upkeep.output import Output
|
|
30
|
+
from mac_upkeep.tasks import TASKS, _load_state, run_all_tasks
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
help="Automated macOS mac-upkeep CLI.\n\n"
|
|
34
|
+
"Runs 11 tasks: brew update/upgrade, gcloud, pnpm, uv, fisher, "
|
|
35
|
+
"mo clean/optimize/purge, brew cleanup, brew bundle cleanup.\n\n"
|
|
36
|
+
"Install: brew install calvindotsg/tap/mac-upkeep\n\n"
|
|
37
|
+
"Schedule: brew services start mac-upkeep (Monday 12 PM weekly)\n\n"
|
|
38
|
+
"Config: ~/.config/mac-upkeep/config.toml",
|
|
39
|
+
no_args_is_help=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _complete_force(ctx: typer.Context, incomplete: str) -> list[tuple[str, str]]:
|
|
44
|
+
"""Shell completion for --force task names."""
|
|
45
|
+
try:
|
|
46
|
+
config = Config.load()
|
|
47
|
+
task_names = {
|
|
48
|
+
name: config.task_defs[name].description
|
|
49
|
+
for name in config.run_order
|
|
50
|
+
if name in config.task_defs
|
|
51
|
+
}
|
|
52
|
+
except Exception:
|
|
53
|
+
task_names = TASKS # fallback to import-time defaults
|
|
54
|
+
already = set(ctx.params.get("force") or [])
|
|
55
|
+
completions: list[tuple[str, str]] = []
|
|
56
|
+
if "all".startswith(incomplete) and "all" not in already:
|
|
57
|
+
completions.append(("all", "Force all tasks"))
|
|
58
|
+
for name, desc in task_names.items():
|
|
59
|
+
if name.startswith(incomplete) and name not in already:
|
|
60
|
+
completions.append((name, desc))
|
|
61
|
+
return completions
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _setup_logging(debug: bool = False) -> None:
|
|
65
|
+
level = logging.DEBUG if debug else logging.INFO
|
|
66
|
+
logging.basicConfig(
|
|
67
|
+
format="[mac-upkeep] %(asctime)s %(message)s",
|
|
68
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
69
|
+
level=level,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _handle_signal(signum: int, _frame: object) -> None:
|
|
74
|
+
logging.getLogger("mac_upkeep").warning("Interrupted (signal %d)", signum)
|
|
75
|
+
sys.exit(130)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
79
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _version_callback(value: bool) -> None:
|
|
83
|
+
if value:
|
|
84
|
+
try:
|
|
85
|
+
v = pkg_version("mac-upkeep")
|
|
86
|
+
except Exception:
|
|
87
|
+
v = "unknown"
|
|
88
|
+
typer.echo(f"mac-upkeep {v}")
|
|
89
|
+
raise typer.Exit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.callback()
|
|
93
|
+
def main(
|
|
94
|
+
_version: Annotated[
|
|
95
|
+
bool | None,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--version", "-v", callback=_version_callback, is_eager=True, help="Show version."
|
|
98
|
+
),
|
|
99
|
+
] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Automated macOS mac-upkeep CLI."""
|
|
102
|
+
if sys.platform != "darwin":
|
|
103
|
+
typer.echo("mac-upkeep requires macOS.", err=True)
|
|
104
|
+
raise typer.Exit(code=1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def run(
|
|
109
|
+
dry_run: Annotated[
|
|
110
|
+
bool, typer.Option("--dry-run", "-n", help="Preview tasks without executing.")
|
|
111
|
+
] = False,
|
|
112
|
+
debug: Annotated[bool, typer.Option("--debug", help="Show detailed debug output.")] = False,
|
|
113
|
+
force: Annotated[
|
|
114
|
+
list[str] | None,
|
|
115
|
+
typer.Option(
|
|
116
|
+
"--force",
|
|
117
|
+
"-f",
|
|
118
|
+
help="Run only specified task(s), ignoring schedule. Repeat for multiple.",
|
|
119
|
+
autocompletion=_complete_force,
|
|
120
|
+
),
|
|
121
|
+
] = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Run all mac-upkeep tasks.
|
|
124
|
+
|
|
125
|
+
Tasks are auto-detected: missing tools are skipped with a log message.
|
|
126
|
+
Disable specific tasks via config file or MAC_UPKEEP_<TASK>=false environment variables.
|
|
127
|
+
|
|
128
|
+
Task order: brew_update, brew_upgrade, gcloud, pnpm, uv, fisher,
|
|
129
|
+
mo_clean, mo_optimize, mo_purge, brew_cleanup, brew_bundle.
|
|
130
|
+
brew_cleanup runs after mo_clean (which runs brew autoremove).
|
|
131
|
+
brew_bundle runs last (homebrew/brew#21350).
|
|
132
|
+
|
|
133
|
+
Exit codes: 0 = completed (some tasks may be skipped), 130 = interrupted.
|
|
134
|
+
"""
|
|
135
|
+
_setup_logging(debug)
|
|
136
|
+
config = Config.load()
|
|
137
|
+
output = Output(debug=debug)
|
|
138
|
+
|
|
139
|
+
# Validate and convert --force option
|
|
140
|
+
force_set: set[str] | None = None
|
|
141
|
+
if force is not None:
|
|
142
|
+
valid_names = set(config.run_order)
|
|
143
|
+
if "all" in force:
|
|
144
|
+
force_set = valid_names
|
|
145
|
+
else:
|
|
146
|
+
invalid = [t for t in force if t not in valid_names]
|
|
147
|
+
if invalid:
|
|
148
|
+
typer.echo(f"Unknown task(s): {', '.join(invalid)}", err=True)
|
|
149
|
+
typer.echo(f"Valid tasks: {', '.join(config.run_order)}", err=True)
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
force_set = set(force)
|
|
152
|
+
|
|
153
|
+
output.header(dry_run=dry_run, task_names=config.run_order)
|
|
154
|
+
results = run_all_tasks(config=config, output=output, dry_run=dry_run, force_tasks=force_set)
|
|
155
|
+
output.summary(results)
|
|
156
|
+
|
|
157
|
+
if config.notify and not dry_run:
|
|
158
|
+
title, message, subtitle = format_summary(results)
|
|
159
|
+
brew_prefix = get_brew_prefix()
|
|
160
|
+
log_url = f"file://{brew_prefix}/var/log/mac-upkeep.log"
|
|
161
|
+
bundle_id = detect_terminal_bundle_id()
|
|
162
|
+
notify(
|
|
163
|
+
title,
|
|
164
|
+
message,
|
|
165
|
+
subtitle=subtitle,
|
|
166
|
+
sound=config.notify_sound,
|
|
167
|
+
activate_bundle_id=bundle_id,
|
|
168
|
+
open_url=log_url,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command()
|
|
173
|
+
def tasks() -> None:
|
|
174
|
+
"""List all tasks with frequency, status, and last run time."""
|
|
175
|
+
config = Config.load()
|
|
176
|
+
state = _load_state()
|
|
177
|
+
|
|
178
|
+
task_list = [
|
|
179
|
+
(name, config.task_defs[name]) for name in config.run_order if name in config.task_defs
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
if sys.stdout.isatty():
|
|
183
|
+
from rich.console import Console
|
|
184
|
+
from rich.table import Table
|
|
185
|
+
|
|
186
|
+
table = Table(title="Tasks", title_style="bold", box=None, padding=(0, 2))
|
|
187
|
+
table.add_column("Task", min_width=14)
|
|
188
|
+
table.add_column("Description", min_width=20)
|
|
189
|
+
table.add_column("Frequency", min_width=8)
|
|
190
|
+
table.add_column("Enabled", min_width=7)
|
|
191
|
+
table.add_column("Last Run", min_width=10)
|
|
192
|
+
|
|
193
|
+
for name, td in task_list:
|
|
194
|
+
enabled = "[green]yes[/green]" if td.enabled else "[dim]no[/dim]"
|
|
195
|
+
last_run = state.get(name, "never")
|
|
196
|
+
table.add_row(name, td.description, td.frequency, enabled, last_run)
|
|
197
|
+
|
|
198
|
+
Console(highlight=False).print(table)
|
|
199
|
+
else:
|
|
200
|
+
for name, td in task_list:
|
|
201
|
+
enabled = "yes" if td.enabled else "no"
|
|
202
|
+
last_run = state.get(name, "never")
|
|
203
|
+
typer.echo(f"{name}\t{td.description}\t{td.frequency}\t{enabled}\t{last_run}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command()
|
|
207
|
+
def init(
|
|
208
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite existing config.")] = False,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Generate a starter config based on detected tools.
|
|
211
|
+
|
|
212
|
+
Probes your system to discover installed tools and writes a commented
|
|
213
|
+
config to ~/.config/mac-upkeep/config.toml. Only detected tasks are
|
|
214
|
+
listed. Built-in defaults apply automatically — uncomment to override.
|
|
215
|
+
"""
|
|
216
|
+
config_path = DEFAULT_CONFIG_PATH
|
|
217
|
+
|
|
218
|
+
if config_path.is_file() and not force:
|
|
219
|
+
typer.echo(f"Config already exists: {config_path}")
|
|
220
|
+
typer.echo("Use --force to overwrite.")
|
|
221
|
+
raise typer.Exit(1)
|
|
222
|
+
|
|
223
|
+
defaults = _load_defaults()
|
|
224
|
+
variables = _build_variables("")
|
|
225
|
+
tasks_data = defaults.get("tasks", {})
|
|
226
|
+
run_order = defaults.get("run", {}).get("order", list(tasks_data.keys()))
|
|
227
|
+
|
|
228
|
+
detected: list[tuple[str, dict]] = []
|
|
229
|
+
not_detected: list[tuple[str, dict]] = []
|
|
230
|
+
|
|
231
|
+
for task_name in run_order:
|
|
232
|
+
data = tasks_data.get(task_name, {})
|
|
233
|
+
detect_raw = data.get("detect", "")
|
|
234
|
+
if detect_raw:
|
|
235
|
+
try:
|
|
236
|
+
detect_bin = resolve_variables(detect_raw, variables)
|
|
237
|
+
except ValueError:
|
|
238
|
+
detect_bin = detect_raw
|
|
239
|
+
else:
|
|
240
|
+
detect_bin = ""
|
|
241
|
+
|
|
242
|
+
if detect_bin and shutil.which(detect_bin):
|
|
243
|
+
detected.append((task_name, data))
|
|
244
|
+
else:
|
|
245
|
+
not_detected.append((task_name, data))
|
|
246
|
+
|
|
247
|
+
config_text = _generate_init_config(detected, not_detected)
|
|
248
|
+
|
|
249
|
+
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
config_path.write_text(config_text)
|
|
251
|
+
typer.echo(f"Created {config_path}")
|
|
252
|
+
typer.echo(f" {len(detected)} tasks detected, {len(not_detected)} not found")
|
|
253
|
+
typer.echo(" Edit the file to customize. Run 'mac-upkeep tasks' to see status.")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _generate_init_config(
|
|
257
|
+
detected: list[tuple[str, dict]],
|
|
258
|
+
not_detected: list[tuple[str, dict]],
|
|
259
|
+
) -> str:
|
|
260
|
+
"""Generate an all-comments config TOML string from detection results."""
|
|
261
|
+
from datetime import date
|
|
262
|
+
|
|
263
|
+
lines: list[str] = []
|
|
264
|
+
total = len(detected) + len(not_detected)
|
|
265
|
+
|
|
266
|
+
lines.append("# mac-upkeep configuration")
|
|
267
|
+
lines.append(
|
|
268
|
+
f"# Generated {date.today()} — {len(detected)} of {total} tasks detected on this system."
|
|
269
|
+
)
|
|
270
|
+
lines.append("#")
|
|
271
|
+
lines.append("# Built-in defaults apply automatically. Only uncomment lines to change.")
|
|
272
|
+
lines.append("# Run 'mac-upkeep tasks' to see task status and last run times.")
|
|
273
|
+
lines.append("# Run 'mac-upkeep show-config --default' to see all defaults.")
|
|
274
|
+
lines.append("")
|
|
275
|
+
|
|
276
|
+
if detected:
|
|
277
|
+
lines.append("# ── Detected tasks (enabled by default) ─────────────────────────")
|
|
278
|
+
for task_name, data in detected:
|
|
279
|
+
desc = data.get("description", "")
|
|
280
|
+
freq = data.get("frequency", "weekly")
|
|
281
|
+
lines.append(f"# {task_name:<16} {desc:<44} {freq}")
|
|
282
|
+
lines.append("")
|
|
283
|
+
|
|
284
|
+
if not_detected:
|
|
285
|
+
lines.append("# ── Not detected (install to enable) ───────────────────────────")
|
|
286
|
+
for task_name, data in not_detected:
|
|
287
|
+
desc = data.get("description", "")
|
|
288
|
+
detect_bin = data.get("detect", "?").split("/")[-1]
|
|
289
|
+
lines.append(f"# {task_name:<16} {desc:<44} ({detect_bin} not found)")
|
|
290
|
+
lines.append("")
|
|
291
|
+
|
|
292
|
+
lines.append("# ── Customize " + "─" * 54)
|
|
293
|
+
lines.append("# [tasks.brew_update]")
|
|
294
|
+
lines.append('# frequency = "monthly"')
|
|
295
|
+
lines.append("#")
|
|
296
|
+
lines.append("# [tasks.fisher]")
|
|
297
|
+
lines.append("# enabled = false")
|
|
298
|
+
lines.append("#")
|
|
299
|
+
lines.append("# [tasks.docker_prune]")
|
|
300
|
+
lines.append('# description = "Prune Docker system"')
|
|
301
|
+
lines.append('# command = "docker system prune -f"')
|
|
302
|
+
lines.append('# detect = "docker"')
|
|
303
|
+
lines.append('# frequency = "monthly"')
|
|
304
|
+
lines.append("#")
|
|
305
|
+
if detected:
|
|
306
|
+
order_str = str([t for t, _ in detected]).replace("'", '"')
|
|
307
|
+
lines.append("# [run]")
|
|
308
|
+
lines.append(f"# order = {order_str}")
|
|
309
|
+
lines.append("")
|
|
310
|
+
|
|
311
|
+
return "\n".join(lines)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command(name="show-config")
|
|
315
|
+
def show_config(
|
|
316
|
+
default: Annotated[
|
|
317
|
+
bool, typer.Option("--default", help="Show bundled defaults (all tasks).")
|
|
318
|
+
] = False,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Show configuration as TOML.
|
|
321
|
+
|
|
322
|
+
With --default: outputs the bundled defaults.toml (all tasks and options).
|
|
323
|
+
Without --default: outputs the user's config overrides, or a setup message.
|
|
324
|
+
"""
|
|
325
|
+
if default:
|
|
326
|
+
text = (
|
|
327
|
+
importlib.resources.files("mac_upkeep")
|
|
328
|
+
.joinpath("defaults.toml")
|
|
329
|
+
.read_text(encoding="utf-8")
|
|
330
|
+
)
|
|
331
|
+
typer.echo(text.rstrip())
|
|
332
|
+
else:
|
|
333
|
+
if DEFAULT_CONFIG_PATH.is_file():
|
|
334
|
+
typer.echo(DEFAULT_CONFIG_PATH.read_text().rstrip())
|
|
335
|
+
else:
|
|
336
|
+
typer.echo(f"No config file found at {DEFAULT_CONFIG_PATH}")
|
|
337
|
+
typer.echo(
|
|
338
|
+
"Run 'mac-upkeep init' to generate one, or "
|
|
339
|
+
"'mac-upkeep show-config --default' to see all defaults."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@app.command(name="notify-test")
|
|
344
|
+
def notify_test() -> None:
|
|
345
|
+
"""Send a test notification to verify macOS permissions.
|
|
346
|
+
|
|
347
|
+
Useful after initial setup to confirm notifications are allowed in
|
|
348
|
+
System Settings > Notifications.
|
|
349
|
+
"""
|
|
350
|
+
config = Config.load()
|
|
351
|
+
brew_prefix = get_brew_prefix()
|
|
352
|
+
bundle_id = detect_terminal_bundle_id()
|
|
353
|
+
log_url = f"file://{brew_prefix}/var/log/mac-upkeep.log"
|
|
354
|
+
ok = notify(
|
|
355
|
+
"mac-upkeep",
|
|
356
|
+
"Test notification",
|
|
357
|
+
sound=config.notify_sound,
|
|
358
|
+
activate_bundle_id=bundle_id,
|
|
359
|
+
open_url=log_url,
|
|
360
|
+
)
|
|
361
|
+
if ok:
|
|
362
|
+
typer.echo("Notification sent. Check your notification center.")
|
|
363
|
+
else:
|
|
364
|
+
typer.echo("Notification failed. Check System Settings > Notifications.")
|
|
365
|
+
raise typer.Exit(1)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.command()
|
|
369
|
+
def setup() -> None:
|
|
370
|
+
"""Print sudoers rules for this machine.
|
|
371
|
+
|
|
372
|
+
Generates machine-specific rules using your username and Homebrew prefix.
|
|
373
|
+
Pipe to sudoers::
|
|
374
|
+
|
|
375
|
+
mac-upkeep setup | sudo tee /etc/sudoers.d/mac-upkeep
|
|
376
|
+
sudo chmod 0440 /etc/sudoers.d/mac-upkeep
|
|
377
|
+
|
|
378
|
+
The env_keep line preserves HOME so mole operates on your home directory,
|
|
379
|
+
not /var/root (which is the default when running via sudo).
|
|
380
|
+
"""
|
|
381
|
+
user = getpass.getuser()
|
|
382
|
+
brew_prefix = get_brew_prefix()
|
|
383
|
+
mo_bin = f"{brew_prefix}/bin/mo"
|
|
384
|
+
|
|
385
|
+
typer.echo(f"# Sudoers rules for mac-upkeep CLI ({user}@{brew_prefix})")
|
|
386
|
+
typer.echo(
|
|
387
|
+
"# Install: mac-upkeep setup | sudo tee /etc/sudoers.d/mac-upkeep"
|
|
388
|
+
" && sudo chmod 0440 /etc/sudoers.d/mac-upkeep"
|
|
389
|
+
)
|
|
390
|
+
typer.echo()
|
|
391
|
+
typer.echo(f'Defaults!{mo_bin} env_keep += "HOME"')
|
|
392
|
+
typer.echo(f"{user} ALL = (root) NOPASSWD: {mo_bin} clean")
|
|
393
|
+
typer.echo(f"{user} ALL = (root) NOPASSWD: {mo_bin} optimize")
|
|
394
|
+
typer.echo()
|
|
395
|
+
typer.echo("# Log rotation (install separately):")
|
|
396
|
+
log_path = f"{brew_prefix}/var/log/mac-upkeep.log"
|
|
397
|
+
typer.echo(f"# echo '{log_path} {user}:admin 644 12 * $M1D0 GN'")
|
|
398
|
+
typer.echo("# | sudo tee /etc/newsyslog.d/mac-upkeep.conf")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.command()
|
|
402
|
+
def status() -> None:
|
|
403
|
+
"""Show brew service status for mac-upkeep."""
|
|
404
|
+
try:
|
|
405
|
+
result = subprocess.run(
|
|
406
|
+
["brew", "services", "info", "mac-upkeep"],
|
|
407
|
+
capture_output=True,
|
|
408
|
+
text=True,
|
|
409
|
+
)
|
|
410
|
+
typer.echo(result.stdout.strip())
|
|
411
|
+
except FileNotFoundError:
|
|
412
|
+
typer.echo("brew not found. Install Homebrew first.")
|
|
413
|
+
raise typer.Exit(1)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.command()
|
|
417
|
+
def logs(
|
|
418
|
+
follow: Annotated[bool, typer.Option("-f", "--follow", help="Follow log output.")] = False,
|
|
419
|
+
lines: Annotated[int, typer.Argument(help="Number of lines to show.")] = 20,
|
|
420
|
+
) -> None:
|
|
421
|
+
"""View mac-upkeep log output.
|
|
422
|
+
|
|
423
|
+
Logs are written by brew services to $(brew --prefix)/var/log/mac-upkeep.log.
|
|
424
|
+
"""
|
|
425
|
+
brew_prefix = get_brew_prefix()
|
|
426
|
+
log_file = Path(brew_prefix) / "var" / "log" / "mac-upkeep.log"
|
|
427
|
+
|
|
428
|
+
if not log_file.is_file():
|
|
429
|
+
typer.echo(f"No log file found at {log_file}")
|
|
430
|
+
raise typer.Exit(1)
|
|
431
|
+
|
|
432
|
+
cmd = ["tail"]
|
|
433
|
+
if follow:
|
|
434
|
+
cmd.append("-f")
|
|
435
|
+
else:
|
|
436
|
+
cmd.extend(["-n", str(lines)])
|
|
437
|
+
cmd.append(str(log_file))
|
|
438
|
+
|
|
439
|
+
os.execvp("tail", cmd)
|
mac_upkeep/config.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Configuration loading from TOML files and environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
|
16
|
+
DEFAULT_CONFIG_DIR = Path(_xdg) / "mac-upkeep"
|
|
17
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TaskDef:
|
|
22
|
+
"""A task definition loaded from TOML."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
description: str
|
|
26
|
+
command: str
|
|
27
|
+
detect: str = ""
|
|
28
|
+
frequency: str = "weekly"
|
|
29
|
+
enabled: bool = True
|
|
30
|
+
sudo: bool = False
|
|
31
|
+
shell: str = ""
|
|
32
|
+
require_file: str = ""
|
|
33
|
+
timeout: int = 300
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_brew_prefix() -> str:
|
|
37
|
+
"""Detect Homebrew prefix (portable: Apple Silicon /opt/homebrew, Intel /usr/local)."""
|
|
38
|
+
brew = shutil.which("brew")
|
|
39
|
+
if brew:
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run([brew, "--prefix"], capture_output=True, text=True, timeout=5)
|
|
42
|
+
return result.stdout.strip()
|
|
43
|
+
except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
|
|
44
|
+
pass
|
|
45
|
+
return "/opt/homebrew" if os.uname().machine == "arm64" else "/usr/local"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_variables(value: str, variables: dict[str, str]) -> str:
|
|
49
|
+
"""Replace ${VAR} placeholders with values. Raises ValueError on unknown vars."""
|
|
50
|
+
for var_name, var_value in variables.items():
|
|
51
|
+
value = value.replace(f"${{{var_name}}}", var_value)
|
|
52
|
+
unresolved = re.findall(r"\$\{(\w+)\}", value)
|
|
53
|
+
if unresolved:
|
|
54
|
+
msg = ", ".join(f"${{{v}}}" for v in unresolved)
|
|
55
|
+
raise ValueError(f"Unknown variable(s): {msg}")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_variables(brewfile: str) -> dict[str, str]:
|
|
60
|
+
"""Build the variable dict for template resolution."""
|
|
61
|
+
return {
|
|
62
|
+
"BREW_PREFIX": get_brew_prefix(),
|
|
63
|
+
"BREWFILE": brewfile or "",
|
|
64
|
+
"HOME": str(Path.home()),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_defaults() -> dict:
|
|
69
|
+
"""Load bundled defaults.toml via importlib.resources."""
|
|
70
|
+
text = (
|
|
71
|
+
importlib.resources.files("mac_upkeep")
|
|
72
|
+
.joinpath("defaults.toml")
|
|
73
|
+
.read_text(encoding="utf-8")
|
|
74
|
+
)
|
|
75
|
+
return tomllib.loads(text)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_default_task_names() -> tuple[dict[str, str], list[str]]:
|
|
79
|
+
"""Load task names and order from bundled defaults.toml.
|
|
80
|
+
|
|
81
|
+
Returns (task_name_to_description, run_order). Used at import time
|
|
82
|
+
by tasks.py for shell completion.
|
|
83
|
+
"""
|
|
84
|
+
data = _load_defaults()
|
|
85
|
+
tasks = {name: defn.get("description", "") for name, defn in data.get("tasks", {}).items()}
|
|
86
|
+
order = data.get("run", {}).get("order", list(tasks.keys()))
|
|
87
|
+
return tasks, order
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_task_defs(
|
|
91
|
+
user_data: dict | None,
|
|
92
|
+
variables: dict[str, str],
|
|
93
|
+
) -> tuple[dict[str, TaskDef], list[str]]:
|
|
94
|
+
"""Load task definitions from defaults.toml, merge with user config.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
user_data: Pre-parsed user TOML dict (or None if no user config).
|
|
98
|
+
variables: Variable dict for ${VAR} resolution.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
(task_defs, run_order)
|
|
102
|
+
"""
|
|
103
|
+
defaults = _load_defaults()
|
|
104
|
+
|
|
105
|
+
# Parse default tasks
|
|
106
|
+
task_defs: dict[str, TaskDef] = {}
|
|
107
|
+
for name, data in defaults.get("tasks", {}).items():
|
|
108
|
+
task_defs[name] = _parse_task_def(name, data)
|
|
109
|
+
|
|
110
|
+
# Default run order
|
|
111
|
+
run_order = defaults.get("run", {}).get("order", list(task_defs.keys()))
|
|
112
|
+
|
|
113
|
+
# Merge user overrides
|
|
114
|
+
if user_data:
|
|
115
|
+
for name, user_fields in user_data.get("tasks", {}).items():
|
|
116
|
+
if name in task_defs:
|
|
117
|
+
# Field-level override: only specified fields change
|
|
118
|
+
td = task_defs[name]
|
|
119
|
+
for field_name, value in user_fields.items():
|
|
120
|
+
if hasattr(td, field_name):
|
|
121
|
+
setattr(td, field_name, value)
|
|
122
|
+
else:
|
|
123
|
+
# New custom task
|
|
124
|
+
task_defs[name] = _parse_task_def(name, user_fields)
|
|
125
|
+
|
|
126
|
+
# User run order replaces default entirely
|
|
127
|
+
if "run" in user_data and "order" in user_data["run"]:
|
|
128
|
+
run_order = user_data["run"]["order"]
|
|
129
|
+
else:
|
|
130
|
+
# Auto-append custom tasks to default order
|
|
131
|
+
for name in user_data.get("tasks", {}):
|
|
132
|
+
if name not in run_order:
|
|
133
|
+
run_order.append(name)
|
|
134
|
+
|
|
135
|
+
# Validate task definitions
|
|
136
|
+
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
|
+
raise ValueError(
|
|
141
|
+
f"Task '{name}': frequency must be 'weekly' or 'monthly', got '{td.frequency}'"
|
|
142
|
+
)
|
|
143
|
+
for entry in run_order:
|
|
144
|
+
if entry not in task_defs:
|
|
145
|
+
raise ValueError(f"run.order references unknown task '{entry}'")
|
|
146
|
+
|
|
147
|
+
# Env var overrides (MAC_UPKEEP_<TASK>=false, MAC_UPKEEP_<TASK>_FREQUENCY=monthly)
|
|
148
|
+
for task_name, td in task_defs.items():
|
|
149
|
+
env_key = f"MAC_UPKEEP_{task_name.upper()}"
|
|
150
|
+
env_val = os.environ.get(env_key)
|
|
151
|
+
if env_val is not None:
|
|
152
|
+
td.enabled = env_val.lower() not in ("false", "0", "no")
|
|
153
|
+
|
|
154
|
+
freq_key = f"MAC_UPKEEP_{task_name.upper()}_FREQUENCY"
|
|
155
|
+
freq_val = os.environ.get(freq_key)
|
|
156
|
+
if freq_val is not None:
|
|
157
|
+
td.frequency = freq_val.lower()
|
|
158
|
+
|
|
159
|
+
# Resolve variables in command, detect, require_file
|
|
160
|
+
for td in task_defs.values():
|
|
161
|
+
td.command = resolve_variables(td.command, variables)
|
|
162
|
+
if td.detect:
|
|
163
|
+
td.detect = resolve_variables(td.detect, variables)
|
|
164
|
+
if td.require_file:
|
|
165
|
+
td.require_file = resolve_variables(td.require_file, variables)
|
|
166
|
+
|
|
167
|
+
# Auto-infer detect from command for tasks that don't set it
|
|
168
|
+
for td in task_defs.values():
|
|
169
|
+
if not td.detect and td.command:
|
|
170
|
+
td.detect = shlex.split(td.command)[0]
|
|
171
|
+
|
|
172
|
+
return task_defs, run_order
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _parse_task_def(name: str, data: dict) -> TaskDef:
|
|
176
|
+
"""Parse a TOML task table into a TaskDef."""
|
|
177
|
+
return TaskDef(
|
|
178
|
+
name=name,
|
|
179
|
+
description=data.get("description", ""),
|
|
180
|
+
command=data.get("command", ""),
|
|
181
|
+
detect=data.get("detect", ""),
|
|
182
|
+
frequency=data.get("frequency", "weekly"),
|
|
183
|
+
enabled=data.get("enabled", True),
|
|
184
|
+
sudo=data.get("sudo", False),
|
|
185
|
+
shell=data.get("shell", ""),
|
|
186
|
+
require_file=data.get("require_file", ""),
|
|
187
|
+
timeout=data.get("timeout", 300),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class Config:
|
|
193
|
+
"""mac-upkeep configuration loaded from TOML + environment overrides."""
|
|
194
|
+
|
|
195
|
+
task_defs: dict[str, TaskDef] = field(default_factory=dict)
|
|
196
|
+
run_order: list[str] = field(default_factory=list)
|
|
197
|
+
brewfile: str | None = None
|
|
198
|
+
notify: bool = True
|
|
199
|
+
notify_sound: str = "Submarine"
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
|
203
|
+
"""Load config from TOML file, then apply environment variable overrides."""
|
|
204
|
+
config = cls()
|
|
205
|
+
|
|
206
|
+
# Read user TOML once (used for both settings and task overrides)
|
|
207
|
+
user_data: dict | None = None
|
|
208
|
+
if path.is_file():
|
|
209
|
+
with open(path, "rb") as f:
|
|
210
|
+
user_data = tomllib.load(f)
|
|
211
|
+
|
|
212
|
+
# Extract notifications from user config
|
|
213
|
+
if user_data and "notifications" in user_data:
|
|
214
|
+
notif = user_data["notifications"]
|
|
215
|
+
if "enabled" in notif:
|
|
216
|
+
config.notify = bool(notif["enabled"])
|
|
217
|
+
if "sound" in notif:
|
|
218
|
+
config.notify_sound = str(notif["sound"])
|
|
219
|
+
|
|
220
|
+
# Extract brewfile from user config
|
|
221
|
+
if user_data and "paths" in user_data and "brewfile" in user_data["paths"]:
|
|
222
|
+
config.brewfile = user_data["paths"]["brewfile"]
|
|
223
|
+
|
|
224
|
+
# Notification env override
|
|
225
|
+
env_notify = os.environ.get("MAC_UPKEEP_NOTIFY")
|
|
226
|
+
if env_notify is not None:
|
|
227
|
+
config.notify = env_notify.lower() not in ("false", "0", "no")
|
|
228
|
+
|
|
229
|
+
# Brewfile path: env var → config file → HOMEBREW_BUNDLE_FILE → auto-discover
|
|
230
|
+
if os.environ.get("MAC_UPKEEP_BREWFILE"):
|
|
231
|
+
config.brewfile = os.environ["MAC_UPKEEP_BREWFILE"]
|
|
232
|
+
if not config.brewfile:
|
|
233
|
+
config.brewfile = os.environ.get("HOMEBREW_BUNDLE_FILE")
|
|
234
|
+
if not config.brewfile:
|
|
235
|
+
config.brewfile = _discover_brewfile()
|
|
236
|
+
|
|
237
|
+
# Build variables and load task definitions
|
|
238
|
+
variables = _build_variables(config.brewfile or "")
|
|
239
|
+
config.task_defs, config.run_order = load_task_defs(user_data, variables)
|
|
240
|
+
|
|
241
|
+
return config
|
|
242
|
+
|
|
243
|
+
def is_enabled(self, task: str) -> bool:
|
|
244
|
+
"""Check if a task is enabled in config."""
|
|
245
|
+
td = self.task_defs.get(task)
|
|
246
|
+
return td.enabled if td else True
|
|
247
|
+
|
|
248
|
+
def get_frequency(self, task: str) -> str:
|
|
249
|
+
"""Get the frequency for a task ('weekly' or 'monthly')."""
|
|
250
|
+
td = self.task_defs.get(task)
|
|
251
|
+
return td.frequency if td else "weekly"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _discover_brewfile() -> str | None:
|
|
255
|
+
"""Auto-discover Brewfile from common locations."""
|
|
256
|
+
candidates = [
|
|
257
|
+
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "Brewfile",
|
|
258
|
+
Path.home() / ".Brewfile",
|
|
259
|
+
Path("Brewfile"),
|
|
260
|
+
]
|
|
261
|
+
for candidate in candidates:
|
|
262
|
+
if candidate.is_file():
|
|
263
|
+
return str(candidate)
|
|
264
|
+
return None
|