mac-upkeep 3.0.2__tar.gz → 3.0.4__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-3.0.4/.github/dependabot.yml +20 -0
- mac_upkeep-3.0.4/.github/workflows/pr-checks.yml +42 -0
- mac_upkeep-3.0.4/.github/workflows/release.yml +176 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/.github/workflows/test.yml +2 -2
- mac_upkeep-3.0.4/.release-please-manifest.json +3 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/CHANGELOG.md +19 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/CLAUDE.md +93 -3
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/PKG-INFO +65 -1
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/README.md +64 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/pyproject.toml +1 -1
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/cli.py +40 -8
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/config.py +120 -25
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/git_sync.py +85 -6
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/output.py +65 -8
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/tasks.py +124 -53
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/conftest.py +12 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_cli.py +68 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_config.py +109 -1
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_git_sync.py +193 -6
- mac_upkeep-3.0.4/tests/test_output.py +251 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_tasks.py +127 -2
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/uv.lock +1 -1
- mac_upkeep-3.0.2/.github/workflows/release.yml +0 -91
- mac_upkeep-3.0.2/.release-please-manifest.json +0 -3
- mac_upkeep-3.0.2/tests/test_output.py +0 -131
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/.gitignore +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/CONTRIBUTING.md +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/LICENSE +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/demo/demo.gif +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/demo/record.sh +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/docs/reusable-patterns.md +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/llms.txt +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/release-please-config.json +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/__init__.py +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/defaults.toml +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/editor_cache.py +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/notify.py +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/src/mac_upkeep/py.typed +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/__init__.py +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_editor_cache.py +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_isolation.py +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_notify.py +0 -0
- {mac_upkeep-3.0.2 → mac_upkeep-3.0.4}/tests/test_retry_backoff.py +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Actions are pinned to full commit SHAs, which is only safe if the pins are
|
|
2
|
+
# maintained rather than frozen. Dependabot updates a SHA pin and rewrites the
|
|
3
|
+
# trailing `# vX.Y.Z` comment alongside it.
|
|
4
|
+
version: 2
|
|
5
|
+
updates:
|
|
6
|
+
- package-ecosystem: github-actions
|
|
7
|
+
directory: /
|
|
8
|
+
schedule:
|
|
9
|
+
interval: weekly
|
|
10
|
+
commit-message:
|
|
11
|
+
prefix: ci
|
|
12
|
+
# NOTE: `ci` DOES cut a release in this repo - v3.0.2 was produced by a single
|
|
13
|
+
# `ci:` commit. So each grouped Dependabot PR will open a release-please PR.
|
|
14
|
+
# That is triage, not breakage: close a release PR whose only contents are
|
|
15
|
+
# action-pin bumps, exactly as for any other ci/docs/chore-only release PR.
|
|
16
|
+
# Weekly is kept deliberately - stale SHA pins are the risk this file exists
|
|
17
|
+
# to manage, and pin freshness matters more than release-PR noise.
|
|
18
|
+
groups:
|
|
19
|
+
actions:
|
|
20
|
+
patterns: ["*"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Reject a `Release-As:` trailer at the point it would enter the repository.
|
|
2
|
+
#
|
|
3
|
+
# squash_merge_commit_message is COMMIT_MESSAGES, so a squashed PR's body is the
|
|
4
|
+
# contributor's own commit messages. release-please parses a `Release-As:` footer
|
|
5
|
+
# and short-circuits all version derivation, so a merged benign-looking PR can
|
|
6
|
+
# dictate the published version -- and PyPI never permits version reuse.
|
|
7
|
+
#
|
|
8
|
+
# An anchored semver check in release.yml is NOT sufficient on its own: the
|
|
9
|
+
# payload `Release-As: 99.0.0` yields the perfectly well-formed tag v99.0.0.
|
|
10
|
+
# release.yml's verify-version job catches the jump; this catches the trailer.
|
|
11
|
+
name: pr-checks
|
|
12
|
+
on:
|
|
13
|
+
pull_request:
|
|
14
|
+
branches: [main]
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
jobs:
|
|
18
|
+
no-release-as:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
22
|
+
with:
|
|
23
|
+
fetch-depth: 0
|
|
24
|
+
- name: Reject Release-As trailers
|
|
25
|
+
env:
|
|
26
|
+
BASE: ${{ github.event.pull_request.base.sha }}
|
|
27
|
+
HEAD: ${{ github.event.pull_request.head.sha }}
|
|
28
|
+
# squash_merge_commit_title is COMMIT_OR_PR_TITLE, so for a multi-commit PR
|
|
29
|
+
# the squashed subject comes from the PR title -- which is not in git log.
|
|
30
|
+
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
31
|
+
PR_BODY: ${{ github.event.pull_request.body }}
|
|
32
|
+
run: |
|
|
33
|
+
set -euo pipefail
|
|
34
|
+
{
|
|
35
|
+
git log --format='%B' "$BASE..$HEAD"
|
|
36
|
+
printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY"
|
|
37
|
+
} > /tmp/release-as-scan.txt
|
|
38
|
+
if grep -qiE '^[[:space:]]*Release-As:' /tmp/release-as-scan.txt; then
|
|
39
|
+
echo "::error::A Release-As trailer overrides release-please version derivation. Remove it."
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
echo "No Release-As trailer found."
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
permissions:
|
|
6
|
+
contents: write
|
|
7
|
+
pull-requests: write
|
|
8
|
+
jobs:
|
|
9
|
+
release-please:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
outputs:
|
|
12
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
13
|
+
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
16
|
+
id: app-token
|
|
17
|
+
with:
|
|
18
|
+
client-id: ${{ vars.RELEASE_PLEASE_CLIENT_ID }}
|
|
19
|
+
private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}
|
|
20
|
+
- id: release
|
|
21
|
+
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
|
22
|
+
with:
|
|
23
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
24
|
+
config-file: release-please-config.json
|
|
25
|
+
|
|
26
|
+
- name: Checkout release PR branch
|
|
27
|
+
if: ${{ steps.release.outputs.pr && !steps.release.outputs.release_created }}
|
|
28
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
29
|
+
with:
|
|
30
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
31
|
+
ref: release-please--branches--main
|
|
32
|
+
|
|
33
|
+
- name: Setup uv
|
|
34
|
+
if: ${{ steps.release.outputs.pr && !steps.release.outputs.release_created }}
|
|
35
|
+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
36
|
+
|
|
37
|
+
- name: Update uv.lock in release PR
|
|
38
|
+
if: ${{ steps.release.outputs.pr && !steps.release.outputs.release_created }}
|
|
39
|
+
env:
|
|
40
|
+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
41
|
+
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
|
42
|
+
run: |
|
|
43
|
+
uv lock
|
|
44
|
+
if git diff --quiet uv.lock; then
|
|
45
|
+
echo "uv.lock already up to date"
|
|
46
|
+
else
|
|
47
|
+
BOT_USER_ID=$(gh api "/users/${APP_SLUG}[bot]" --jq .id)
|
|
48
|
+
git config user.name "${APP_SLUG}[bot]"
|
|
49
|
+
git config user.email "${BOT_USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
|
|
50
|
+
git add uv.lock
|
|
51
|
+
git commit -m "chore: update uv.lock"
|
|
52
|
+
git push
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Gate BOTH release_created consumers. release-please honours a `Release-As:`
|
|
56
|
+
# footer and short-circuits all version derivation, and squash merges put a
|
|
57
|
+
# contributor's own commit messages into the commit that lands on main -- so a
|
|
58
|
+
# merged PR can dictate the published version. An anchored semver regex alone
|
|
59
|
+
# does NOT help: the payload `Release-As: 99.0.0` produces the perfectly
|
|
60
|
+
# well-formed tag v99.0.0. The size of the jump is what gives it away.
|
|
61
|
+
verify-version:
|
|
62
|
+
needs: release-please
|
|
63
|
+
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
permissions:
|
|
66
|
+
contents: read
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
69
|
+
with:
|
|
70
|
+
fetch-depth: 0
|
|
71
|
+
- name: Refuse implausible version jumps
|
|
72
|
+
env:
|
|
73
|
+
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
74
|
+
run: |
|
|
75
|
+
set -euo pipefail
|
|
76
|
+
[[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || {
|
|
77
|
+
echo "::error::Refusing malformed tag: $TAG"; exit 1; }
|
|
78
|
+
new_major=${BASH_REMATCH[1]}; new_minor=${BASH_REMATCH[2]}; new_patch=${BASH_REMATCH[3]}
|
|
79
|
+
|
|
80
|
+
# Compare against the highest EXISTING release tag, not the manifest:
|
|
81
|
+
# by the time this job runs the release PR has merged, so
|
|
82
|
+
# .release-please-manifest.json already holds the new version and would
|
|
83
|
+
# always compare equal.
|
|
84
|
+
prev=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
|
|
85
|
+
| grep -v -x -F "$TAG" | head -n1 || true)
|
|
86
|
+
if [[ -z "$prev" ]]; then
|
|
87
|
+
echo "No previous release tag; skipping jump check."
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
[[ "$prev" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || {
|
|
91
|
+
echo "::error::Unparseable previous tag: $prev"; exit 1; }
|
|
92
|
+
cur_major=${BASH_REMATCH[1]}; cur_minor=${BASH_REMATCH[2]}; cur_patch=${BASH_REMATCH[3]}
|
|
93
|
+
|
|
94
|
+
# Allowlist the three bumps release-please can actually produce, rather than
|
|
95
|
+
# bounding only upward major/minor movement -- a one-sided check accepts any
|
|
96
|
+
# version DECREASE and any patch jump, so `Release-As: 3.0.99` sails through.
|
|
97
|
+
if (( new_major == cur_major + 1 && new_minor == 0 && new_patch == 0 )); then :
|
|
98
|
+
elif (( new_major == cur_major && new_minor == cur_minor + 1 && new_patch == 0 )); then :
|
|
99
|
+
elif (( new_major == cur_major && new_minor == cur_minor \
|
|
100
|
+
&& new_patch == cur_patch + 1 )); then :
|
|
101
|
+
else
|
|
102
|
+
echo "::error::Implausible bump $prev -> $TAG; a Release-As trailer may have been merged."
|
|
103
|
+
echo "::error::Expected one of: v$((cur_major + 1)).0.0, v${cur_major}.$((cur_minor + 1)).0, v${cur_major}.${cur_minor}.$((cur_patch + 1))"
|
|
104
|
+
exit 1
|
|
105
|
+
fi
|
|
106
|
+
echo "$prev -> $TAG is a normal bump."
|
|
107
|
+
|
|
108
|
+
# Build is separated from publish so that anything injected into the build
|
|
109
|
+
# environment cannot reach the OIDC credentials. `[build-system] requires` is
|
|
110
|
+
# unbounded and uv.lock does not cover build requirements at all, so six
|
|
111
|
+
# packages resolve fresh from the live index and then execute here. PyPA's own
|
|
112
|
+
# guidance: "only set the id-token: write permission in the job that does
|
|
113
|
+
# publishing... try to separate building from publishing".
|
|
114
|
+
build:
|
|
115
|
+
needs: [release-please, verify-version]
|
|
116
|
+
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
|
117
|
+
runs-on: ubuntu-latest
|
|
118
|
+
permissions:
|
|
119
|
+
contents: read
|
|
120
|
+
steps:
|
|
121
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
122
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
123
|
+
- run: uv build
|
|
124
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
125
|
+
with:
|
|
126
|
+
name: dist
|
|
127
|
+
path: dist/
|
|
128
|
+
if-no-files-found: error
|
|
129
|
+
|
|
130
|
+
pypi-publish:
|
|
131
|
+
needs: build
|
|
132
|
+
runs-on: ubuntu-latest
|
|
133
|
+
environment: pypi
|
|
134
|
+
permissions:
|
|
135
|
+
id-token: write
|
|
136
|
+
steps:
|
|
137
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
138
|
+
with:
|
|
139
|
+
name: dist
|
|
140
|
+
path: dist/
|
|
141
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
142
|
+
|
|
143
|
+
bump-tap:
|
|
144
|
+
needs: [release-please, verify-version]
|
|
145
|
+
if: ${{ needs.release-please.outputs.release_created == 'true' }}
|
|
146
|
+
runs-on: ubuntu-latest
|
|
147
|
+
steps:
|
|
148
|
+
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
|
149
|
+
id: app-token
|
|
150
|
+
with:
|
|
151
|
+
client-id: ${{ vars.RELEASE_PLEASE_CLIENT_ID }}
|
|
152
|
+
private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}
|
|
153
|
+
repositories: homebrew-tap
|
|
154
|
+
# Build the payload with jq rather than string interpolation, so payload
|
|
155
|
+
# STRUCTURE can never depend on tag content. A double quote is a legal git
|
|
156
|
+
# ref character, and an interpolated tag containing one produced malformed
|
|
157
|
+
# JSON that silently broke the tap bump.
|
|
158
|
+
- id: payload
|
|
159
|
+
env:
|
|
160
|
+
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
161
|
+
run: |
|
|
162
|
+
set -euo pipefail
|
|
163
|
+
json=$(jq -cn --arg tag "$TAG" '{
|
|
164
|
+
formula: "mac-upkeep",
|
|
165
|
+
version: $tag,
|
|
166
|
+
type: "python",
|
|
167
|
+
url: ("https://github.com/calvindotsg/mac-upkeep/archive/refs/tags/" + $tag + ".tar.gz")
|
|
168
|
+
}')
|
|
169
|
+
echo "json=$json" >> "$GITHUB_OUTPUT"
|
|
170
|
+
- name: Dispatch formula update
|
|
171
|
+
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
|
172
|
+
with:
|
|
173
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
174
|
+
repository: calvindotsg/homebrew-tap
|
|
175
|
+
event-type: formula-update
|
|
176
|
+
client-payload: ${{ steps.payload.outputs.json }}
|
|
@@ -11,8 +11,8 @@ jobs:
|
|
|
11
11
|
matrix:
|
|
12
12
|
python-version: ["3.11", "3.12", "3.13"]
|
|
13
13
|
steps:
|
|
14
|
-
- uses: actions/checkout@v7
|
|
15
|
-
- uses: astral-sh/setup-uv@v10.0.1
|
|
14
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
15
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
16
16
|
with:
|
|
17
17
|
python-version: ${{ matrix.python-version }}
|
|
18
18
|
- run: uv lock --check
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.0.4](https://github.com/calvindotsg/mac-upkeep/compare/v3.0.3...v3.0.4) (2026-08-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* close six findings from security audit run-1 ([#53](https://github.com/calvindotsg/mac-upkeep/issues/53)) ([c83f865](https://github.com/calvindotsg/mac-upkeep/commit/c83f86587c435a07356639a06c26300a829d67cc))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### CI/CD
|
|
12
|
+
|
|
13
|
+
* harden the release pipeline against findings F-05, F-06 and F-11 ([#54](https://github.com/calvindotsg/mac-upkeep/issues/54)) ([1d1fb5f](https://github.com/calvindotsg/mac-upkeep/commit/1d1fb5fd2e99e0e0d9bc6bd72da34e244fce44f4))
|
|
14
|
+
|
|
15
|
+
## [3.0.3](https://github.com/calvindotsg/mac-upkeep/compare/v3.0.2...v3.0.3) (2026-08-17)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* report handler tasks as dry-run instead of as having executed ([#51](https://github.com/calvindotsg/mac-upkeep/issues/51)) ([1db8054](https://github.com/calvindotsg/mac-upkeep/commit/1db8054d65da313f6fe4d51f00932d126fc59c79))
|
|
21
|
+
|
|
3
22
|
## [3.0.2](https://github.com/calvindotsg/mac-upkeep/compare/v3.0.1...v3.0.2) (2026-08-16)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -23,9 +23,10 @@ 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, format_last_run(), format_next_run()
|
|
26
|
+
frequency scheduling, format_last_run(), format_next_run()
|
|
27
27
|
cli.py → Typer app: run, tasks, init, show-config, setup, status (dashboard), logs, notify-test
|
|
28
|
-
output.py → TaskResult dataclass, Rich Live table TUI (interactive), Python logging
|
|
28
|
+
output.py → TaskResult dataclass, Rich Live table TUI (interactive), Python logging
|
|
29
|
+
(non-interactive), strip_control_sequences() (shared sanitiser)
|
|
29
30
|
notify.py → macOS notifications via terminal-notifier (preferred) / osascript (fallback)
|
|
30
31
|
```
|
|
31
32
|
|
|
@@ -103,6 +104,95 @@ Reclaims Electron/editor caches mole's classifier structurally misses (`Service
|
|
|
103
104
|
- **`_is_safe_target`** refuses anything not ≥2 segments below `~/Library/Application Support`, and refuses symlinks — mirrors mole's `validate_path_for_deletion`.
|
|
104
105
|
- Ships **`enabled = false`** (opt-in: it's `rm -rf` and reaches all users, like `pnpm`). Targets default to Notion + Zed; override with `[[editor_cache.apps]]` (`name`/`process`/`min_size_mb`/`targets`) in user config.
|
|
105
106
|
|
|
107
|
+
### Fail-closed contracts (security audit run-1)
|
|
108
|
+
|
|
109
|
+
Four gates were changed from fail-open to fail-closed. Each looks like an inconsequential
|
|
110
|
+
default until you notice which direction it fails in; do not "simplify" them back.
|
|
111
|
+
|
|
112
|
+
- **`Config.is_enabled()` returns `False` for an unknown key**, not `True`. Every caller
|
|
113
|
+
derives the key from a real `TaskDef` via `normalize_task_key()`, so a miss means the two
|
|
114
|
+
keyspaces diverged — which must never resolve to "run it". This is only safe *because*
|
|
115
|
+
keys are normalised at load: alone, it would stop a legitimately-enabled custom task with
|
|
116
|
+
a capitalised name from ever running. The two changes ship together or not at all.
|
|
117
|
+
- **`normalize_task_key()` is applied once in `load_task_defs()`** — to default keys, user
|
|
118
|
+
keys, and `run.order` entries — so `tasks.py`'s `task_key` derivation is an identity
|
|
119
|
+
operation. Before, `[tasks.DockerPrune]` was stored raw, `is_enabled` missed, and
|
|
120
|
+
`enabled = false` was ignored while the dashboard cheerfully printed "disabled". Two names
|
|
121
|
+
normalising to one key is a `ValueError`, because otherwise the winner depends on TOML
|
|
122
|
+
table order.
|
|
123
|
+
- **User field types are validated** (`_FIELD_TYPES` + `_check_field`) in *both* the override
|
|
124
|
+
branch and `_parse_task_def`. `enabled = "false"` — quoting a TOML boolean, the most common
|
|
125
|
+
TOML mistake — used to leave a truthy string and run the task anyway; `sudo = "false"`
|
|
126
|
+
*added* `sudo -n`. `bool` subclasses `int`, so `timeout = true` needs the explicit
|
|
127
|
+
`isinstance(value, bool)` rejection.
|
|
128
|
+
- **`MAC_UPKEEP_<TASK>` is an allowlist of truthy strings**, not a denylist of falsy ones.
|
|
129
|
+
`off`, `disabled` and `""` previously all enabled the task.
|
|
130
|
+
|
|
131
|
+
### require_file must distinguish "unset" from "resolved to nothing"
|
|
132
|
+
|
|
133
|
+
`TaskDef.require_file_declared` records that the template was non-empty *before* variable
|
|
134
|
+
resolution. The old guard `if td.require_file and not Path(...).is_file()` short-circuited on
|
|
135
|
+
the empty string, so `brew_bundle` ran `--file=` — which Homebrew treats as absent, falling
|
|
136
|
+
back to `$PWD/Brewfile`, evaluating it as Ruby and then uninstalling everything it does not
|
|
137
|
+
list. It also failed on every run for every user with no Brewfile, burning retry backoff and
|
|
138
|
+
a notification each time. Related: `_discover_brewfile()` must never regain a CWD-relative
|
|
139
|
+
candidate, and `run_task` passes `cwd="/"` so no task can be steered by the caller's
|
|
140
|
+
directory (launchd already provides `/`).
|
|
141
|
+
|
|
142
|
+
### git_sync: repository config is untrusted input
|
|
143
|
+
|
|
144
|
+
A repo's own `.git/config` can make git execute a command, and `safe.directory` does not
|
|
145
|
+
help — it keys on ownership, and an extracted archive is owned by the invoking user.
|
|
146
|
+
`_run_git` therefore prepends `_trusted_overrides()` to **every** call, not just `pull`:
|
|
147
|
+
`git status --porcelain`, the `skip_dirty` *safety* check, is itself enough to trigger
|
|
148
|
+
`core.fsmonitor` and filter drivers. All sinks below were verified to fire on git 2.55.
|
|
149
|
+
|
|
150
|
+
- `_STATIC_OVERRIDES` are unconditional: `core.fsmonitor=`, `core.hooksPath=/dev/null`,
|
|
151
|
+
`protocol.ext.allow=never` (a repo can set `protocol.ext.allow=always` and get `ext::`
|
|
152
|
+
back), `protocol.file.allow=never` (the only working block for `remote.<n>.uploadpack`,
|
|
153
|
+
which a per-name `-c` override does *not* beat), and `protocol.git.allow=never` (the only
|
|
154
|
+
working block for `core.gitProxy` — `-c core.gitProxy=` does **not** disable it).
|
|
155
|
+
- **Multi-valued and single-valued keys need opposite treatment, and confusing them is a
|
|
156
|
+
live outage.** `_INHERITED_LIST_KEYS` (`credential.helper`) is reset with an empty entry —
|
|
157
|
+
which for a list key means "discard what git accumulated" — then the user's global/system
|
|
158
|
+
values are appended back. A bare reset without the re-add would break nearly every macOS
|
|
159
|
+
user, because Homebrew git ships `credential.helper = osxkeychain` at *system* scope, and
|
|
160
|
+
the helper fires on an HTTP 401 despite `GIT_ASKPASS` and `GIT_TERMINAL_PROMPT`.
|
|
161
|
+
`_INHERITED_SCALAR_KEYS` (`core.sshCommand`) must instead be **set** to the user's value
|
|
162
|
+
or to an explicit default. An empty entry there is not a reset: git execs the empty string,
|
|
163
|
+
and every SSH remote fails with `error: cannot run :` on every run — a retry storm for the
|
|
164
|
+
default configuration, which is the one the README documents.
|
|
165
|
+
- **Known residual, do not claim otherwise:** `filter.<driver>.clean` from a planted
|
|
166
|
+
`.gitattributes` still executes on `status`. Driver names are arbitrary, so no fixed `-c`
|
|
167
|
+
set covers them, and git has no "ignore repo-local config" switch. Enrolment discipline is
|
|
168
|
+
the real control. Before adding a "this is the only remaining sink" claim anywhere, re-derive
|
|
169
|
+
the list against the installed git — `core.gitProxy` was missed on the first pass precisely
|
|
170
|
+
because the sink list was taken as complete rather than re-tested.
|
|
171
|
+
- `_trusted_cache` is process-global; `tests/conftest.py` seeds it with the static half so
|
|
172
|
+
argv is deterministic and the `git config` probes are not caught by tests that patch
|
|
173
|
+
`git_sync.subprocess.run`. Tests exercising the inherited half reset it to `None`.
|
|
174
|
+
|
|
175
|
+
### Untrusted text is never Rich markup
|
|
176
|
+
|
|
177
|
+
`output.py` renders subprocess and remote-derived text as a literal `rich.text.Text`, never
|
|
178
|
+
interpolated into a markup string — in `task_debug` *and* in `summary`'s failure detail. A
|
|
179
|
+
bracketed path like `[/usr/local]` raises `MarkupError` (which used to abort the run and
|
|
180
|
+
leave the cursor hidden, since `Live.__exit__` never ran), and `[link=…]` renders a real
|
|
181
|
+
OSC-8 hyperlink with attacker-chosen target and anchor text inside our own failure summary.
|
|
182
|
+
`strip_control_sequences()` in `output.py` is the single sanitiser for both `tasks.py` and
|
|
183
|
+
`git_sync.py`; it covers all escape sequences plus stray C0/C1 characters, not just SGR
|
|
184
|
+
colour codes. It deliberately does **not** touch `[` — markup is handled by `Text`, not by
|
|
185
|
+
escaping.
|
|
186
|
+
|
|
187
|
+
### No single task may abort the run
|
|
188
|
+
|
|
189
|
+
`run_task` catches `OSError` (a shim whose shebang interpreter vanished is the routine
|
|
190
|
+
trigger), `run_all_tasks` wraps each task in a catch-all, and `cli.run` passes its own
|
|
191
|
+
`results` list in so `output.summary()` and `notify()` still describe whatever completed.
|
|
192
|
+
Under launchd the notification is the user's only feedback channel, and `summary()` is what
|
|
193
|
+
exits the Rich `Live` context. The dead `CalledProcessError` branch was removed — `check=True`
|
|
194
|
+
is never passed, so it read as coverage that did not exist.
|
|
195
|
+
|
|
106
196
|
### sudo + HOME/USER/LOGNAME
|
|
107
197
|
|
|
108
198
|
`sudo -n` with full path `$BREW_PREFIX/bin/mo`. Sudoers `env_keep += "HOME USER LOGNAME"`. The `sudo` field in `TaskDef` exists instead of embedding `sudo` in the command so that: (a) dry-run can skip sudo, (b) `detect` infers the correct binary, (c) `mac-upkeep setup` can generate sudoers rules.
|
|
@@ -139,7 +229,7 @@ env USER=root MOLE_DRY_RUN=1 mo optimize | grep -A2 'Permission Repair' # enters
|
|
|
139
229
|
- **Config/state paths are import-time constants**, so setting `XDG_CONFIG_HOME` from a fixture is too late — patch `mac_upkeep.config.DEFAULT_CONFIG_PATH`, `mac_upkeep.cli.DEFAULT_CONFIG_PATH`, `tasks._STATE_FILE` and `tasks._RETRY_FILE` objects directly. `Config.load(path=None)` resolves `DEFAULT_CONFIG_PATH` **at call time**; as a default argument it would bind at def time and silently ignore every patch.
|
|
140
230
|
- **`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.
|
|
141
231
|
- **`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. A genuine stall (network/server hang) still hits the timeout, so `_run_git` catches `subprocess.TimeoutExpired` and returns a synthetic `CompletedProcess(returncode=124, stderr="timed out after {timeout}s")`. That marks only the affected repo failed and lets the loop continue — a hung pull cannot abort the whole run (which would otherwise skip `output.summary()`/`notify()` and later tasks).
|
|
142
|
-
- **`git_sync` failure reasons belong in the aggregate `TaskResult`, not only `output.task_debug()`** — debug output is suppressed without `--debug
|
|
232
|
+
- **`git_sync` failure reasons belong in the aggregate `TaskResult`, not only `output.task_debug()`** — debug output is suppressed without `--debug` on the non-interactive path (`Output.debug` is set but not consulted by the interactive branch, so a TTY always sees it), so the log recorded `13 failed: <names>` with no cause, which is not diagnosable after the fact. `_format_failures()` groups repos by reason because the common case is one shared cause (a network drop failing every repo at once); it caps names per group and truncates long messages so a 16-repo outage cannot blow up the log line or the notification body.
|
|
143
233
|
|
|
144
234
|
## Release Process
|
|
145
235
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mac-upkeep
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.4
|
|
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
|
|
@@ -127,6 +127,23 @@ frequency = "monthly"
|
|
|
127
127
|
brewfile = "~/.config/Brewfile"
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
+
Task fields are type-checked. TOML booleans are unquoted — `enabled = false`, not
|
|
131
|
+
`enabled = "false"` — and a quoted one is now rejected with an error rather than
|
|
132
|
+
silently leaving the task enabled. Task names are matched case- and space-insensitively
|
|
133
|
+
(`[tasks.Docker Prune]` and `[tasks.docker_prune]` are the same task, and declaring both
|
|
134
|
+
is an error).
|
|
135
|
+
|
|
136
|
+
#### Brewfile discovery
|
|
137
|
+
|
|
138
|
+
With no `[paths] brewfile` and no `MAC_UPKEEP_BREWFILE`/`HOMEBREW_BUNDLE_FILE`, only two
|
|
139
|
+
absolute locations are searched: `$XDG_CONFIG_HOME/Brewfile` (default `~/.config/Brewfile`)
|
|
140
|
+
and `~/.Brewfile`. **The current working directory is deliberately not searched.**
|
|
141
|
+
`brew bundle` evaluates a Brewfile as Ruby and `brew_bundle` runs `cleanup --force`, so a
|
|
142
|
+
CWD-relative lookup made whichever project directory your shell happened to be in able to
|
|
143
|
+
run code and uninstall every package it did not list. If no Brewfile is found the task
|
|
144
|
+
skips; it no longer runs with an empty `--file=`, which Homebrew resolved back to
|
|
145
|
+
`$PWD/Brewfile`.
|
|
146
|
+
|
|
130
147
|
### Custom tasks
|
|
131
148
|
|
|
132
149
|
Add your own tasks using the same format:
|
|
@@ -158,6 +175,32 @@ skip_dirty = true # skip repos with uncommitted changes
|
|
|
158
175
|
|
|
159
176
|
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
177
|
|
|
178
|
+
#### Only enrol repositories you created
|
|
179
|
+
|
|
180
|
+
**A git repository's own `.git/config` can make git execute commands.** Point a `repos` glob only at directories you created yourself — never at a downloads, sync, backup, or vendor-drop directory. A tree that arrives by archive, restore, or file sync brings its `.git/config` and `.git/hooks` with it, and `safe.directory` does not help: it keys on ownership, and anything you unpacked is owned by you. A plain `git clone` does *not* carry these, so cloning is unaffected.
|
|
181
|
+
|
|
182
|
+
mac-upkeep neutralises the directives it can, on every git call — not just on `pull`, because `git status --porcelain` (the `skip_dirty` check itself) is enough to trigger some of them:
|
|
183
|
+
|
|
184
|
+
| Neutralised | How |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `core.fsmonitor` | reset to empty |
|
|
187
|
+
| `.git/hooks/*` | `core.hooksPath=/dev/null` |
|
|
188
|
+
| `credential.helper` | list reset, then your own global/system helpers re-added |
|
|
189
|
+
| `core.sshCommand` | set to your own global/system value, else explicitly `ssh` |
|
|
190
|
+
| `ext::` transport | `protocol.ext.allow=never` |
|
|
191
|
+
| `remote.<name>.uploadpack` | `protocol.file.allow=never` |
|
|
192
|
+
| `core.gitProxy` | `protocol.git.allow=never` |
|
|
193
|
+
|
|
194
|
+
Note the asymmetry in the middle two rows. `credential.helper` is multi-valued, so an empty entry resets git's accumulated list. `core.sshCommand` is single-valued, so an empty entry is not a reset — git would try to execute the empty string and every SSH remote would fail. Single-valued keys are therefore *set*, never blanked.
|
|
195
|
+
|
|
196
|
+
Three consequences worth knowing:
|
|
197
|
+
|
|
198
|
+
- **Local-path remotes no longer work** under git_sync (`fatal: transport 'file' not allowed`). This is deliberate — it is what closes the `uploadpack` execution path. Pull from a bare mirror on an external disk outside mac-upkeep.
|
|
199
|
+
- **`git://` remotes no longer work** (`fatal: transport 'git' not allowed`). Also deliberate: `core.gitProxy` runs an arbitrary command for that transport and cannot be neutralised any other way. `git://` is unauthenticated and unencrypted; use SSH or HTTPS.
|
|
200
|
+
- **Repository hooks do not run** during git_sync, so a `post-merge` hook that installs dependencies will not fire on an unattended pull.
|
|
201
|
+
|
|
202
|
+
This is defence in depth, not a sandbox. Git has no "ignore this repository's config" switch. The known remaining execution path is `filter.<driver>.clean` from a planted `.gitattributes`, which fires on `git status`: driver names are arbitrary, so no fixed override covers them. Enrolment discipline is the control that actually holds.
|
|
203
|
+
|
|
161
204
|
#### Authentication
|
|
162
205
|
|
|
163
206
|
Any of the following work under launchd without mac-upkeep-side configuration:
|
|
@@ -175,6 +218,11 @@ MAC_UPKEEP_GCLOUD=false mac-upkeep run # Disable a task
|
|
|
175
218
|
MAC_UPKEEP_GCLOUD_FREQUENCY=monthly mac-upkeep run # Override frequency
|
|
176
219
|
```
|
|
177
220
|
|
|
221
|
+
`MAC_UPKEEP_<TASK>` enables the task only for an explicitly truthy value —
|
|
222
|
+
`true`, `1`, `yes`, or `on`. **Anything else disables it**, including `off`, `disabled`
|
|
223
|
+
and the empty string, which previously all *enabled* the task by falling through a
|
|
224
|
+
denylist of `false`/`0`/`no`.
|
|
225
|
+
|
|
178
226
|
### Sudoers
|
|
179
227
|
|
|
180
228
|
`mo_clean` and `mo_optimize` require passwordless sudo for the `mo` binary:
|
|
@@ -195,6 +243,22 @@ Validate before installing — a malformed file in `/etc/sudoers.d/` can lock yo
|
|
|
195
243
|
>
|
|
196
244
|
> 3.0.0 generates `env_keep += "HOME USER LOGNAME"`. Verify with `sudo -n $(brew --prefix)/bin/mo optimize </dev/null >/dev/null; echo $?` — it should print `0`.
|
|
197
245
|
|
|
246
|
+
### Log file permissions
|
|
247
|
+
|
|
248
|
+
`mac-upkeep setup` now prints the `newsyslog.d` line with mode **640** instead of 644. The
|
|
249
|
+
log records `git_sync` failures by repository name, which enumerates your private and
|
|
250
|
+
employer-internal repositories, and `$(brew --prefix)/var/log` is world-traversable — unlike
|
|
251
|
+
`~/Library`, nothing else gates it. Owner plus the `admin` group keeps `mac-upkeep logs`
|
|
252
|
+
working.
|
|
253
|
+
|
|
254
|
+
> **⚠ Already installed?** Like the sudoers file, `/etc/newsyslog.d/mac-upkeep.conf` is
|
|
255
|
+
> **not** upgraded by `brew upgrade`, and existing log files keep their 644 mode. Rewrite
|
|
256
|
+
> the conf from `mac-upkeep setup`, then:
|
|
257
|
+
>
|
|
258
|
+
> ```bash
|
|
259
|
+
> sudo chmod 640 "$(brew --prefix)"/var/log/mac-upkeep.log*
|
|
260
|
+
> ```
|
|
261
|
+
|
|
198
262
|
## Contributing
|
|
199
263
|
|
|
200
264
|
See [CONTRIBUTING.md](https://github.com/calvindotsg/mac-upkeep/blob/main/CONTRIBUTING.md) for development setup and conventions.
|
|
@@ -100,6 +100,23 @@ frequency = "monthly"
|
|
|
100
100
|
brewfile = "~/.config/Brewfile"
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
Task fields are type-checked. TOML booleans are unquoted — `enabled = false`, not
|
|
104
|
+
`enabled = "false"` — and a quoted one is now rejected with an error rather than
|
|
105
|
+
silently leaving the task enabled. Task names are matched case- and space-insensitively
|
|
106
|
+
(`[tasks.Docker Prune]` and `[tasks.docker_prune]` are the same task, and declaring both
|
|
107
|
+
is an error).
|
|
108
|
+
|
|
109
|
+
#### Brewfile discovery
|
|
110
|
+
|
|
111
|
+
With no `[paths] brewfile` and no `MAC_UPKEEP_BREWFILE`/`HOMEBREW_BUNDLE_FILE`, only two
|
|
112
|
+
absolute locations are searched: `$XDG_CONFIG_HOME/Brewfile` (default `~/.config/Brewfile`)
|
|
113
|
+
and `~/.Brewfile`. **The current working directory is deliberately not searched.**
|
|
114
|
+
`brew bundle` evaluates a Brewfile as Ruby and `brew_bundle` runs `cleanup --force`, so a
|
|
115
|
+
CWD-relative lookup made whichever project directory your shell happened to be in able to
|
|
116
|
+
run code and uninstall every package it did not list. If no Brewfile is found the task
|
|
117
|
+
skips; it no longer runs with an empty `--file=`, which Homebrew resolved back to
|
|
118
|
+
`$PWD/Brewfile`.
|
|
119
|
+
|
|
103
120
|
### Custom tasks
|
|
104
121
|
|
|
105
122
|
Add your own tasks using the same format:
|
|
@@ -131,6 +148,32 @@ skip_dirty = true # skip repos with uncommitted changes
|
|
|
131
148
|
|
|
132
149
|
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
150
|
|
|
151
|
+
#### Only enrol repositories you created
|
|
152
|
+
|
|
153
|
+
**A git repository's own `.git/config` can make git execute commands.** Point a `repos` glob only at directories you created yourself — never at a downloads, sync, backup, or vendor-drop directory. A tree that arrives by archive, restore, or file sync brings its `.git/config` and `.git/hooks` with it, and `safe.directory` does not help: it keys on ownership, and anything you unpacked is owned by you. A plain `git clone` does *not* carry these, so cloning is unaffected.
|
|
154
|
+
|
|
155
|
+
mac-upkeep neutralises the directives it can, on every git call — not just on `pull`, because `git status --porcelain` (the `skip_dirty` check itself) is enough to trigger some of them:
|
|
156
|
+
|
|
157
|
+
| Neutralised | How |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `core.fsmonitor` | reset to empty |
|
|
160
|
+
| `.git/hooks/*` | `core.hooksPath=/dev/null` |
|
|
161
|
+
| `credential.helper` | list reset, then your own global/system helpers re-added |
|
|
162
|
+
| `core.sshCommand` | set to your own global/system value, else explicitly `ssh` |
|
|
163
|
+
| `ext::` transport | `protocol.ext.allow=never` |
|
|
164
|
+
| `remote.<name>.uploadpack` | `protocol.file.allow=never` |
|
|
165
|
+
| `core.gitProxy` | `protocol.git.allow=never` |
|
|
166
|
+
|
|
167
|
+
Note the asymmetry in the middle two rows. `credential.helper` is multi-valued, so an empty entry resets git's accumulated list. `core.sshCommand` is single-valued, so an empty entry is not a reset — git would try to execute the empty string and every SSH remote would fail. Single-valued keys are therefore *set*, never blanked.
|
|
168
|
+
|
|
169
|
+
Three consequences worth knowing:
|
|
170
|
+
|
|
171
|
+
- **Local-path remotes no longer work** under git_sync (`fatal: transport 'file' not allowed`). This is deliberate — it is what closes the `uploadpack` execution path. Pull from a bare mirror on an external disk outside mac-upkeep.
|
|
172
|
+
- **`git://` remotes no longer work** (`fatal: transport 'git' not allowed`). Also deliberate: `core.gitProxy` runs an arbitrary command for that transport and cannot be neutralised any other way. `git://` is unauthenticated and unencrypted; use SSH or HTTPS.
|
|
173
|
+
- **Repository hooks do not run** during git_sync, so a `post-merge` hook that installs dependencies will not fire on an unattended pull.
|
|
174
|
+
|
|
175
|
+
This is defence in depth, not a sandbox. Git has no "ignore this repository's config" switch. The known remaining execution path is `filter.<driver>.clean` from a planted `.gitattributes`, which fires on `git status`: driver names are arbitrary, so no fixed override covers them. Enrolment discipline is the control that actually holds.
|
|
176
|
+
|
|
134
177
|
#### Authentication
|
|
135
178
|
|
|
136
179
|
Any of the following work under launchd without mac-upkeep-side configuration:
|
|
@@ -148,6 +191,11 @@ MAC_UPKEEP_GCLOUD=false mac-upkeep run # Disable a task
|
|
|
148
191
|
MAC_UPKEEP_GCLOUD_FREQUENCY=monthly mac-upkeep run # Override frequency
|
|
149
192
|
```
|
|
150
193
|
|
|
194
|
+
`MAC_UPKEEP_<TASK>` enables the task only for an explicitly truthy value —
|
|
195
|
+
`true`, `1`, `yes`, or `on`. **Anything else disables it**, including `off`, `disabled`
|
|
196
|
+
and the empty string, which previously all *enabled* the task by falling through a
|
|
197
|
+
denylist of `false`/`0`/`no`.
|
|
198
|
+
|
|
151
199
|
### Sudoers
|
|
152
200
|
|
|
153
201
|
`mo_clean` and `mo_optimize` require passwordless sudo for the `mo` binary:
|
|
@@ -168,6 +216,22 @@ Validate before installing — a malformed file in `/etc/sudoers.d/` can lock yo
|
|
|
168
216
|
>
|
|
169
217
|
> 3.0.0 generates `env_keep += "HOME USER LOGNAME"`. Verify with `sudo -n $(brew --prefix)/bin/mo optimize </dev/null >/dev/null; echo $?` — it should print `0`.
|
|
170
218
|
|
|
219
|
+
### Log file permissions
|
|
220
|
+
|
|
221
|
+
`mac-upkeep setup` now prints the `newsyslog.d` line with mode **640** instead of 644. The
|
|
222
|
+
log records `git_sync` failures by repository name, which enumerates your private and
|
|
223
|
+
employer-internal repositories, and `$(brew --prefix)/var/log` is world-traversable — unlike
|
|
224
|
+
`~/Library`, nothing else gates it. Owner plus the `admin` group keeps `mac-upkeep logs`
|
|
225
|
+
working.
|
|
226
|
+
|
|
227
|
+
> **⚠ Already installed?** Like the sudoers file, `/etc/newsyslog.d/mac-upkeep.conf` is
|
|
228
|
+
> **not** upgraded by `brew upgrade`, and existing log files keep their 644 mode. Rewrite
|
|
229
|
+
> the conf from `mac-upkeep setup`, then:
|
|
230
|
+
>
|
|
231
|
+
> ```bash
|
|
232
|
+
> sudo chmod 640 "$(brew --prefix)"/var/log/mac-upkeep.log*
|
|
233
|
+
> ```
|
|
234
|
+
|
|
171
235
|
## Contributing
|
|
172
236
|
|
|
173
237
|
See [CONTRIBUTING.md](https://github.com/calvindotsg/mac-upkeep/blob/main/CONTRIBUTING.md) for development setup and conventions.
|