freshenup 3.0.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.
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ check:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+ - uses: astral-sh/setup-uv@v8.3.2
14
+ with:
15
+ enable-cache: true
16
+ - run: uv sync
17
+ - run: uv run ruff format --check src tests
18
+ - run: uv run ruff check src tests
19
+ - run: uv run pyright src tests
20
+ - run: uv run pytest -q
@@ -0,0 +1,19 @@
1
+ name: Publish
2
+
3
+ # Cut a GitHub Release to publish that version to PyPI. Release from a green main —
4
+ # this workflow builds and publishes, it does not re-run the CI gate.
5
+ on:
6
+ release:
7
+ types: [published]
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ id-token: write # mint the short-lived OIDC token for PyPI Trusted Publishing
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - uses: astral-sh/setup-uv@v8.3.2
18
+ - run: uv build
19
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Akira Yamamoto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: freshenup
3
+ Version: 3.0.0
4
+ Summary: Pick outdated Homebrew and Mac App Store items to upgrade or uninstall
5
+ Project-URL: Homepage, https://github.com/akirayamamoto/freshenup
6
+ Project-URL: Repository, https://github.com/akirayamamoto/freshenup
7
+ Project-URL: Issues, https://github.com/akirayamamoto/freshenup/issues
8
+ Author: Akira Yamamoto
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,homebrew,macos,mas,updates
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: System :: Software Distribution
17
+ Requires-Python: >=3.14
18
+ Requires-Dist: pydantic>=2
19
+ Description-Content-Type: text/markdown
20
+
21
+ # freshenup
22
+
23
+ Pick outdated Homebrew formulae and casks — and, optionally, outdated Mac App Store apps — from an `fzf` menu, then upgrade or uninstall them.
24
+
25
+ A formula node is a top-level (leaf) formula that is outdated itself or has outdated dependencies beneath it; a cask or App Store app is a standalone node. Everything starts selected. Keys are shown in the `fzf` header. App Store rows show a slugified name; the numeric id drives the action via `mas` (which needs root, so a `sudo` prompt appears only when an App Store row is selected).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ brew install akirayamamoto/tap/freshenup
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ freshenup # scan and pick
37
+ freshenup -u # force `brew update` first (brew otherwise auto-refreshes at most once/24h)
38
+ ```
39
+
40
+ - **enter** — upgrade the selected nodes
41
+ - **ctrl-x** — uninstall the highlighted node (confirmed)
42
+ - **tab** — toggle selection
43
+ - **ctrl-t** — invert selection
44
+
45
+ ## Dependencies
46
+
47
+ - [`fzf`](https://github.com/junegunn/fzf) — the interactive picker (installed automatically via Homebrew)
48
+ - Python 3.14+ and [`pydantic`](https://docs.pydantic.dev) (bundled by the Homebrew install)
49
+ - Homebrew
50
+ - Optional: [`mas`](https://github.com/mas-cli/mas) for Mac App Store support (detected at runtime)
51
+
52
+ ## Development
53
+
54
+ Managed with [`uv`](https://docs.astral.sh/uv/); lint/format with Ruff, type-checked with pyright (strict).
55
+
56
+ ```bash
57
+ uv sync # create the venv and install deps
58
+ uv run pytest # run tests
59
+ uv run ruff check # lint
60
+ uv run ruff format # format
61
+ uv run pyright # type-check (strict)
62
+ ```
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,46 @@
1
+ # freshenup
2
+
3
+ Pick outdated Homebrew formulae and casks — and, optionally, outdated Mac App Store apps — from an `fzf` menu, then upgrade or uninstall them.
4
+
5
+ A formula node is a top-level (leaf) formula that is outdated itself or has outdated dependencies beneath it; a cask or App Store app is a standalone node. Everything starts selected. Keys are shown in the `fzf` header. App Store rows show a slugified name; the numeric id drives the action via `mas` (which needs root, so a `sudo` prompt appears only when an App Store row is selected).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ brew install akirayamamoto/tap/freshenup
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ freshenup # scan and pick
17
+ freshenup -u # force `brew update` first (brew otherwise auto-refreshes at most once/24h)
18
+ ```
19
+
20
+ - **enter** — upgrade the selected nodes
21
+ - **ctrl-x** — uninstall the highlighted node (confirmed)
22
+ - **tab** — toggle selection
23
+ - **ctrl-t** — invert selection
24
+
25
+ ## Dependencies
26
+
27
+ - [`fzf`](https://github.com/junegunn/fzf) — the interactive picker (installed automatically via Homebrew)
28
+ - Python 3.14+ and [`pydantic`](https://docs.pydantic.dev) (bundled by the Homebrew install)
29
+ - Homebrew
30
+ - Optional: [`mas`](https://github.com/mas-cli/mas) for Mac App Store support (detected at runtime)
31
+
32
+ ## Development
33
+
34
+ Managed with [`uv`](https://docs.astral.sh/uv/); lint/format with Ruff, type-checked with pyright (strict).
35
+
36
+ ```bash
37
+ uv sync # create the venv and install deps
38
+ uv run pytest # run tests
39
+ uv run ruff check # lint
40
+ uv run ruff format # format
41
+ uv run pyright # type-check (strict)
42
+ ```
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,6 @@
1
+ # Releasing
2
+
3
+ 1. Commit to `main`.
4
+ 2. Tag and push: `git tag vX.Y.Z && git push --tags` (semver — your call on the bump).
5
+
6
+ The [Homebrew tap](https://github.com/akirayamamoto/homebrew-tap) auto-bumps its formula within a week. To publish immediately, run the **bump** workflow: Actions → bump → Run workflow.
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env bash
2
+ # freshenup — pick outdated Homebrew nodes (formulae + casks) and, optionally, outdated Mac
3
+ # App Store apps, then act on them. A formula node is a leaf that is outdated itself or has
4
+ # outdated deps beneath it; a cask or App Store app is a standalone node. Everything starts
5
+ # selected. Keys are shown in the fzf header (single source of truth). Updating names the
6
+ # outdated deps directly — `brew upgrade mpv` is a no-op when mpv is current, so it wouldn't
7
+ # pull an outdated dep along. App Store rows show a slugified name; the numeric id drives the
8
+ # action (upgrade/uninstall via `mas`, which needs root — a sudo prompt appears only then).
9
+ # Flag: -u/--update force `brew update` now (brew otherwise auto-refreshes at most once/24h).
10
+ # Deps: fzf, brew, coreutils(comm), awk. Optional: mas (Mac App Store). No config, no state.
11
+ set -euo pipefail
12
+
13
+ # Preview mode (re-invoked by fzf per row): the node's outdated members + version bumps.
14
+ # Payload columns: type <TAB> node <TAB> member <TAB> "current → latest".
15
+ if [ "${1:-}" = "--preview" ]; then
16
+ awk -F'\t' -v n="${2:-}" '
17
+ $2 == n { typ = $1
18
+ if ($3 == n || $1 == "mas") self = $4; else deps = deps sprintf(" %-22s %s\n", $3, $4) }
19
+ END {
20
+ lab["cask"] = " (cask)"; lab["mas"] = " (App Store)"; lab["formula"] = " (formula)"
21
+ print (self == "") ? n lab[typ] : n " " self lab[typ]; printf "%s", deps }' \
22
+ "${BREWTOP_PAYLOAD:-/dev/null}"
23
+ exit 0
24
+ fi
25
+
26
+ SELF="$(command -v -- "$0" 2>/dev/null || true)"; [ -x "$SELF" ] || SELF="$0"
27
+
28
+ # No NO_AUTO_UPDATE override: brew's built-in throttle auto-refreshes at most once every
29
+ # ~24h before outdated/upgrade, so within a day this uses the cache and stays fast.
30
+ if [ "${1:-}" = "-u" ] || [ "${1:-}" = "--update" ]; then
31
+ echo "Refreshing Homebrew…" >&2; brew update >/dev/null
32
+ fi
33
+
34
+ of="$(mktemp)"; oc="$(mktemp)"; lf="$(mktemp)"; vmap="$(mktemp)"; payload="$(mktemp)"; om="$(mktemp)"
35
+ trap 'rm -f "$of" "$oc" "$lf" "$vmap" "$payload" "$om"' EXIT
36
+ has_mas=""; command -v mas >/dev/null 2>&1 && has_mas=1
37
+ msg="Scanning for outdated formulae and casks"
38
+ [ -n "$has_mas" ] && msg="Scanning for outdated formulae, casks, and App Store apps"
39
+ echo "${msg}…" >&2
40
+ # brew's per-call startup is ~2.5s, so run the independent lookups concurrently.
41
+ brew outdated --formula --verbose >"$of" 2>/dev/null &
42
+ brew outdated --cask --verbose >"$oc" 2>/dev/null &
43
+ brew leaves --installed-on-request | sort >"$lf" 2>/dev/null &
44
+ [ -n "$has_mas" ] && mas outdated --json >"$om" 2>/dev/null &
45
+ wait
46
+
47
+ # Version map "name<TAB>current → latest" from "name (…, current) < latest" (formulae)
48
+ # and "name (version) != version" (casks). A cask version's comma-suffix is part of the
49
+ # version — keep it (e.g. dotnet 8.0.422,8.0.28) and only strip a trailing build hash.
50
+ # ponytail: "16+ hex chars" is the hash heuristic; tighten to require an a–f digit if a real
51
+ # version ever gets truncated.
52
+ awk '{
53
+ i = index($0, " ("); if (i == 0) next
54
+ name = substr($0, 1, i-1); rest = substr($0, i+2)
55
+ j = index(rest, ")"); inside = substr(rest, 1, j-1); after = substr(rest, j+1)
56
+ sub(/^ *(<|!=) */, "", after)
57
+ m = split(inside, a, ", "); cur = a[m]
58
+ sub(/,[0-9a-fA-F]{16,}$/, "", cur); sub(/,[0-9a-fA-F]{16,}$/, "", after)
59
+ printf "%s\t%s → %s\n", name, cur, after
60
+ }' "$of" "$oc" > "$vmap"
61
+
62
+ names_in() { awk '{ i=index($0," ("); if (i>0) print substr($0,1,i-1) }' "$1"; }
63
+ outdated="$(names_in "$of")" # outdated formula names
64
+ casks="$(names_in "$oc")" # outdated cask names
65
+ leaves="$(cat "$lf")"; export leaves
66
+
67
+ # Map each outdated formula to its top node:
68
+ # - outdated leaf → the node itself (set intersection)
69
+ # - outdated non-leaf dep → its leaf ancestors (brew uses, one per dep, in parallel)
70
+ build() {
71
+ comm -12 <(echo "$leaves") <(echo "$outdated" | sort) | while read -r l; do
72
+ [ -n "$l" ] && printf '%s\t●\t\n' "$l"
73
+ done
74
+ comm -13 <(echo "$leaves") <(echo "$outdated" | sort) | xargs -P8 -I{} bash -c '
75
+ d="$1"; [ -z "$d" ] && exit 0
76
+ comm -12 <(brew uses --installed --recursive "$d" | sort) <(echo "$leaves") \
77
+ | while read -r a; do [ -n "$a" ] && printf "%s\t\t%s\n" "$a" "$d"; done
78
+ ' _ {} || true
79
+ }
80
+
81
+ # One row per formula node: node <TAB> ●-or-empty <TAB> "dep1 dep2 ".
82
+ pending="$(build | awk -F'\t' '
83
+ { seen[$1]=1; if ($2=="●") self[$1]=1; if ($3!="") deps[$1]=deps[$1] $3 " " }
84
+ END { for (n in seen) printf "%s\t%s\t%s\n", n, (n in self ? "●" : ""), deps[n] }
85
+ ')"
86
+
87
+ # Payload: type <TAB> node <TAB> member <TAB> "current → latest".
88
+ # Formula nodes expand to their outdated members; casks collapse under a depends_on parent.
89
+ awk -F'\t' '
90
+ NR==FNR { ver[$1]=$2; next }
91
+ { if ($2=="●") print "formula\t" $1 "\t" $1 "\t" ver[$1]
92
+ c=split($3,a," "); for (i=1;i<=c;i++) if (a[i]!="") print "formula\t" $1 "\t" a[i] "\t" ver[a[i]] }
93
+ ' "$vmap" <(printf '%s\n' "$pending") > "$payload"
94
+ # Fold an outdated cask under an outdated cask that depends on it (e.g. dotnet-sdk8 →
95
+ # dotnet-sdk8-0-400) so a wrapper and its version band show as one node, like formula deps
96
+ # under their leaf. Deps come from each cask's install receipt (runtime_dependencies) — a
97
+ # ~1KB local read, no brew calls. ponytail: one dep level, covers the wrapper pattern.
98
+ caskroom="${HOMEBREW_PREFIX:-$(dirname "$(dirname "$(command -v brew)")")}/Caskroom"
99
+ cver() { awk -F'\t' -v n="$1" '$1==n{print $2}' "$vmap"; }
100
+ cask_apps() { # installed .app bundle name(s) for a cask, from its receipt's uninstall_artifacts
101
+ awk -F'"' '
102
+ $0 ~ /"app"[[:space:]]*:/ { inapp=1; next }
103
+ inapp && /\]/ { inapp=0; next }
104
+ inapp && NF>=2 { print $2 }' "$caskroom/$1/.metadata/INSTALL_RECEIPT.json" 2>/dev/null
105
+ }
106
+ cdeps="$(while read -r c; do
107
+ [ -z "$c" ] && continue
108
+ awk -F'"' '/"full_name":/ { d = $4; sub(/.*\//, "", d); print c "\t" d }' \
109
+ c="$c" "$caskroom/$c/.metadata/INSTALL_RECEIPT.json" 2>/dev/null
110
+ done <<<"$casks")" # parent<TAB>dep
111
+ folded="$(printf '%s\n' "$cdeps" | awk -F'\t' 'NF==2{print $2}' | sort -u \
112
+ | comm -12 - <(printf '%s\n' "$casks" | sort -u))" # outdated casks shown under a parent
113
+ while read -r c; do
114
+ [ -z "$c" ] && continue
115
+ printf '%s\n' "$folded" | grep -qxF "$c" && continue # this cask folds under a parent
116
+ printf 'cask\t%s\t%s\t%s\n' "$c" "$c" "$(cver "$c")" # top-level node (self row)
117
+ printf '%s\n' "$cdeps" | awk -F'\t' -v c="$c" '$1==c{print $2}' | while read -r d; do
118
+ printf '%s\n' "$folded" | grep -qxF "$d" && printf 'cask\t%s\t%s\t%s\n' "$c" "$d" "$(cver "$d")"
119
+ done
120
+ done <<<"$casks" >> "$payload"
121
+
122
+ # Each outdated Mac App Store app is a standalone self node: display a slugified name + the
123
+ # last 3 id digits (keeps rows visually brew-like and near-unique); the numeric id in the
124
+ # member slot is what `mas` acts on. JSON fields are order-independent, so match each.
125
+ if [ -s "$om" ]; then
126
+ awk '
127
+ { id=""; name=""; cur=""; new=""
128
+ if (match($0, /"adamID":[0-9]+/)) id = substr($0, RSTART+9, RLENGTH-9)
129
+ if (match($0, /"name":"[^"]*"/)) name = substr($0, RSTART+8, RLENGTH-9)
130
+ if (match($0, /"version":"[^"]*"/)) cur = substr($0, RSTART+11, RLENGTH-12)
131
+ if (match($0, /"newVersion":"[^"]*"/)) new = substr($0, RSTART+14, RLENGTH-15)
132
+ if (id == "" || name == "") next
133
+ s = tolower(name); gsub(/[^a-z0-9]+/, "-", s); gsub(/^-+|-+$/, "", s)
134
+ printf "mas\t%s-%s\t%s\t%s\n", s, substr(id, length(id)-2), id, (cur == "" ? new : cur " → " new)
135
+ }' "$om" >> "$payload"
136
+ fi
137
+
138
+ [ ! -s "$payload" ] && { echo "Nothing outdated."; exit 0; }
139
+ export BREWTOP_PAYLOAD="$payload"
140
+
141
+ # ctrl-x prints a marker then the highlighted line only (deselect-all makes accept emit the
142
+ # current item, not the selection — verified in fzf src output()/{} handling). No --expect.
143
+ sel="$(cut -f2 "$payload" | sort -u | fzf -m \
144
+ --bind 'load:select-all' \
145
+ --bind 'ctrl-t:toggle-all' \
146
+ --bind 'ctrl-x:print(%%UNINSTALL%%)+deselect-all+accept' \
147
+ --header=$'enter=update ctrl-x=uninstall highlighted tab=toggle ctrl-t=invert' \
148
+ --preview="$SELF --preview {1}" --preview-window=right,55%)" || exit 0
149
+
150
+ [ -z "$sel" ] && exit 0
151
+
152
+ if [ "$(head -1 <<<"$sel")" = "%%UNINSTALL%%" ]; then
153
+ # Uninstall the single highlighted node (line 2), confirmed; runs here so output persists.
154
+ n="$(sed -n 2p <<<"$sel")"
155
+ [ -z "$n" ] && exit 0
156
+ t="$(awk -F'\t' -v n="$n" '$2==n{print $1; exit}' "$payload")"
157
+ printf 'Uninstall %s (%s)? [y/N] ' "$n" "$t" > /dev/tty
158
+ read -r ans < /dev/tty || ans=""
159
+ case "$ans" in
160
+ y|Y) case "$t" in
161
+ cask) brew uninstall --cask "$n" || true ;;
162
+ mas) mid="$(awk -F'\t' -v n="$n" '$1=="mas" && $2==n {print $3; exit}' "$payload")"
163
+ { [ -n "$mid" ] && sudo mas uninstall "$mid"; } || true ;;
164
+ *) { brew uninstall --formula "$n" && brew autoremove; } || true ;;
165
+ esac ;;
166
+ *) echo " skipped $n" > /dev/tty ;;
167
+ esac
168
+ else
169
+ # Update: gather selected nodes' outdated members, split by type, upgrade in one call each.
170
+ nodes="$sel"
171
+ fpkgs=""; cpkgs=""; mpkgs=""
172
+ while read -r n; do
173
+ [ -z "$n" ] && continue
174
+ while IFS=$'\t' read -r t m; do
175
+ [ -z "$m" ] && continue
176
+ case "$t" in
177
+ cask) cpkgs="$cpkgs$m"$'\n' ;;
178
+ mas) mpkgs="$mpkgs$m"$'\n' ;;
179
+ *) fpkgs="$fpkgs$m"$'\n' ;;
180
+ esac
181
+ done < <(awk -F'\t' -v n="$n" '$2==n { print $1 "\t" $3 }' "$payload")
182
+ done <<<"$nodes"
183
+ fpkgs="$(printf '%s' "$fpkgs" | sed '/^$/d' | sort -u)"
184
+ cpkgs="$(printf '%s' "$cpkgs" | sed '/^$/d' | sort -u)"
185
+ mpkgs="$(printf '%s' "$mpkgs" | sed '/^$/d' | sort -u)"
186
+ # shellcheck disable=SC2086
187
+ [ -n "$fpkgs" ] && brew upgrade --formula $fpkgs
188
+ # A cask whose installed .app is owned by another user (typically root, after a vendor
189
+ # self-updater reinstalled it) can't be upgraded by brew without sudo — brew would `sudo cp`
190
+ # the bundle, which fails non-interactively (e.g. under topgrade) and which freshenup never
191
+ # does. Detect those up front (ownership, not `-w`: App Management protection makes `-w`
192
+ # report false even for your own apps) and per cask offer to chown it back to you or skip it.
193
+ if [ -n "$cpkgs" ]; then
194
+ me="$(id -un)"; ok=""
195
+ while read -r c; do
196
+ [ -z "$c" ] && continue
197
+ root_app=""
198
+ while read -r app; do
199
+ p="/Applications/$app"; [ -e "$p" ] || continue
200
+ # ponytail: owner-based (updaters root the whole bundle); group-writable edge unhandled.
201
+ [ "$(stat -f '%Su' "$p")" != "$me" ] && root_app="$p"
202
+ done < <(cask_apps "$c")
203
+ if [ -z "$root_app" ]; then ok="$ok$c"$'\n'; continue; fi
204
+ o="$(stat -f '%Su' "$root_app")"
205
+ printf '%s is owned by %s — brew needs sudo to upgrade it. chown to you first? [y/N] ' \
206
+ "$c" "$o" > /dev/tty
207
+ read -r ans < /dev/tty || ans=""
208
+ case "$ans" in
209
+ y|Y) sudo chown -R "$me:admin" "$root_app" && ok="$ok$c"$'\n' \
210
+ || echo " chown failed; skipped $c" > /dev/tty ;;
211
+ *) echo " skipped $c (owned by $o)" > /dev/tty ;;
212
+ esac
213
+ done <<<"$cpkgs"
214
+ cpkgs="$(printf '%s' "$ok" | sed '/^$/d' | sort -u)"
215
+ fi
216
+ # shellcheck disable=SC2086
217
+ [ -n "$cpkgs" ] && brew upgrade --cask $cpkgs
218
+ # shellcheck disable=SC2086
219
+ [ -n "$mpkgs" ] && sudo mas upgrade $mpkgs
220
+ true
221
+ fi
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "freshenup"
3
+ version = "3.0.0"
4
+ description = "Pick outdated Homebrew and Mac App Store items to upgrade or uninstall"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Akira Yamamoto" }]
10
+ keywords = ["homebrew", "mas", "cli", "macos", "updates"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: MacOS",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Topic :: System :: Software Distribution",
17
+ ]
18
+ dependencies = ["pydantic>=2"]
19
+
20
+ [project.scripts]
21
+ freshenup = "freshenup.cli:main"
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/akirayamamoto/freshenup"
25
+ Repository = "https://github.com/akirayamamoto/freshenup"
26
+ Issues = "https://github.com/akirayamamoto/freshenup/issues"
27
+
28
+ [dependency-groups]
29
+ dev = ["ruff>=0.10", "pyright>=1.1", "pytest>=8"]
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/freshenup"]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py314"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B", "SIM"]
44
+
45
+ [tool.pyright]
46
+ typeCheckingMode = "strict"
47
+ pythonVersion = "3.14"
48
+ venvPath = "."
49
+ venv = ".venv"
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """freshenup — pick outdated Homebrew and Mac App Store items to upgrade or uninstall."""
2
+
3
+ __version__ = "3.0.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,131 @@
1
+ """Command-line entry: parse flags, then orchestrate scan → pick → act."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import shutil
8
+ import sys
9
+
10
+ from . import system
11
+ from .models import Item, Kind, Node
12
+ from .nodes import build_formula_nodes, collapse_casks
13
+ from .pick import pick, preview
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ args = _parse_args(argv)
18
+ if args.preview is not None:
19
+ print(preview(args.preview))
20
+ return 0
21
+ nodes = _gather(refresh=args.update)
22
+ if not nodes:
23
+ print("Nothing outdated.")
24
+ return 0
25
+ selection = pick(nodes)
26
+ if selection is None:
27
+ return 0
28
+ if selection.uninstall is not None:
29
+ _uninstall(selection.uninstall)
30
+ else:
31
+ _update(selection.nodes)
32
+ return 0
33
+
34
+
35
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser(
37
+ prog="freshenup",
38
+ description="Pick outdated Homebrew and Mac App Store items to upgrade or uninstall.",
39
+ )
40
+ parser.add_argument("-u", "--update", action="store_true", help="run `brew update` first")
41
+ parser.add_argument("--preview", metavar="NODE", help=argparse.SUPPRESS)
42
+ return parser.parse_args(argv)
43
+
44
+
45
+ def _gather(*, refresh: bool) -> list[Node]:
46
+ has_mas = shutil.which("mas") is not None
47
+ kinds = "formulae, casks, and App Store apps" if has_mas else "formulae and casks"
48
+ print(f"Scanning for outdated {kinds}…", file=sys.stderr)
49
+ scanned = system.scan(refresh=refresh, has_mas=has_mas)
50
+ return [
51
+ *build_formula_nodes(scanned.formulae, scanned.leaves, system.uses),
52
+ *collapse_casks(scanned.casks, system.deps_of),
53
+ *_mas_nodes(scanned.mas),
54
+ ]
55
+
56
+
57
+ def _mas_nodes(apps: list[Item]) -> list[Node]:
58
+ # Display a slugified name + the last 3 id digits (near-unique, brew-like); the numeric id in
59
+ # itself.mas_id drives the action.
60
+ return [
61
+ Node(kind=Kind.MAS, name=f"{_slug(app.name)}-{app.mas_id[-3:]}", itself=app) for app in apps
62
+ ]
63
+
64
+
65
+ def _slug(name: str) -> str:
66
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
67
+
68
+
69
+ def _items(node: Node) -> list[Item]:
70
+ items = list(node.members)
71
+ if node.itself is not None:
72
+ items.append(node.itself)
73
+ return items
74
+
75
+
76
+ def _update(nodes: list[Node]) -> None:
77
+ formulae: list[str] = []
78
+ casks: list[str] = []
79
+ mas_ids: list[str] = []
80
+ for node in nodes:
81
+ for item in _items(node):
82
+ if item.kind is Kind.CASK:
83
+ casks.append(item.name)
84
+ elif item.kind is Kind.MAS:
85
+ mas_ids.append(item.mas_id)
86
+ else:
87
+ formulae.append(item.name)
88
+ system.upgrade(Kind.FORMULA, _unique(formulae))
89
+ _upgrade_casks(_unique(casks))
90
+ system.upgrade(Kind.MAS, _unique(mas_ids))
91
+
92
+
93
+ def _upgrade_casks(tokens: list[str]) -> None:
94
+ """Upgrade casks, but first handle any whose .app is owned by another user (brew would need
95
+ sudo and fail): offer to chown it back, else skip that cask."""
96
+ if not tokens:
97
+ return
98
+ me = system.current_user()
99
+ blocked = dict(system.blocked_casks(tokens, system.apps_of, system.owner_of, me))
100
+ approved = [token for token in tokens if token not in blocked]
101
+ for token, owner in blocked.items():
102
+ prompt = (
103
+ f"{token} is owned by {owner} — brew needs sudo to upgrade it. "
104
+ "chown to you first? [y/N] "
105
+ )
106
+ if not _confirm(prompt):
107
+ print(f" skipped {token} (owned by {owner})", file=sys.stderr)
108
+ elif system.chown_cask(token, me):
109
+ approved.append(token)
110
+ else:
111
+ print(f" chown failed; skipped {token}", file=sys.stderr)
112
+ system.upgrade(Kind.CASK, sorted(approved))
113
+
114
+
115
+ def _uninstall(node: Node) -> None:
116
+ if not _confirm(f"Uninstall {node.name} ({node.kind.value})? [y/N] "):
117
+ print(f" skipped {node.name}", file=sys.stderr)
118
+ return
119
+ mas_id = node.itself.mas_id if node.itself is not None else ""
120
+ system.uninstall(node.kind, node.name, mas_id)
121
+
122
+
123
+ def _confirm(prompt: str) -> bool:
124
+ try:
125
+ return input(prompt).strip().lower() == "y"
126
+ except EOFError:
127
+ return False
128
+
129
+
130
+ def _unique(names: list[str]) -> list[str]:
131
+ return sorted({name for name in names if name})
@@ -0,0 +1,39 @@
1
+ """Domain types: the outdated Item the rest of freshenup works with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+
9
+ class Kind(Enum):
10
+ FORMULA = "formula"
11
+ CASK = "cask"
12
+ MAS = "mas"
13
+
14
+ @property
15
+ def label(self) -> str:
16
+ return {Kind.FORMULA: "(formula)", Kind.CASK: "(cask)", Kind.MAS: "(App Store)"}[self]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Item:
21
+ """One outdated package and its version bump. `mas_id` is set only for App Store apps."""
22
+
23
+ kind: Kind
24
+ name: str
25
+ current: str
26
+ latest: str
27
+ mas_id: str = ""
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Node:
32
+ """One fzf row: a top-level package, optionally its own outdated Item (`itself`), plus any
33
+ outdated members folded under it — a formula's outdated dependencies, or casks folded under a
34
+ cask that depends on them."""
35
+
36
+ kind: Kind
37
+ name: str
38
+ itself: Item | None = None
39
+ members: tuple[Item, ...] = ()