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.
- freshenup-3.0.0/.github/workflows/ci.yml +20 -0
- freshenup-3.0.0/.github/workflows/publish.yml +19 -0
- freshenup-3.0.0/.gitignore +6 -0
- freshenup-3.0.0/LICENSE +21 -0
- freshenup-3.0.0/PKG-INFO +66 -0
- freshenup-3.0.0/README.md +46 -0
- freshenup-3.0.0/RELEASING.md +6 -0
- freshenup-3.0.0/freshenup +221 -0
- freshenup-3.0.0/pyproject.toml +52 -0
- freshenup-3.0.0/src/freshenup/__init__.py +3 -0
- freshenup-3.0.0/src/freshenup/__main__.py +3 -0
- freshenup-3.0.0/src/freshenup/cli.py +131 -0
- freshenup-3.0.0/src/freshenup/models.py +39 -0
- freshenup-3.0.0/src/freshenup/nodes.py +62 -0
- freshenup-3.0.0/src/freshenup/parse.py +88 -0
- freshenup-3.0.0/src/freshenup/pick.py +87 -0
- freshenup-3.0.0/src/freshenup/system.py +152 -0
- freshenup-3.0.0/tests/data/mas.json +4 -0
- freshenup-3.0.0/tests/data/outdated_cask.txt +3 -0
- freshenup-3.0.0/tests/data/outdated_formula.txt +2 -0
- freshenup-3.0.0/tests/data/receipt_beyond_compare.json +6 -0
- freshenup-3.0.0/tests/data/receipt_word.json +13 -0
- freshenup-3.0.0/tests/test_nodes.py +71 -0
- freshenup-3.0.0/tests/test_ownership.py +43 -0
- freshenup-3.0.0/tests/test_parse.py +55 -0
- freshenup-3.0.0/uv.lock +222 -0
|
@@ -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
|
freshenup-3.0.0/LICENSE
ADDED
|
@@ -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.
|
freshenup-3.0.0/PKG-INFO
ADDED
|
@@ -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,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, ...] = ()
|