freshenup 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- freshenup/__init__.py +3 -0
- freshenup/__main__.py +3 -0
- freshenup/cli.py +131 -0
- freshenup/models.py +39 -0
- freshenup/nodes.py +62 -0
- freshenup/parse.py +88 -0
- freshenup/pick.py +87 -0
- freshenup/system.py +152 -0
- freshenup-3.0.0.dist-info/METADATA +66 -0
- freshenup-3.0.0.dist-info/RECORD +13 -0
- freshenup-3.0.0.dist-info/WHEEL +4 -0
- freshenup-3.0.0.dist-info/entry_points.txt +2 -0
- freshenup-3.0.0.dist-info/licenses/LICENSE +21 -0
freshenup/__init__.py
ADDED
freshenup/__main__.py
ADDED
freshenup/cli.py
ADDED
|
@@ -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})
|
freshenup/models.py
ADDED
|
@@ -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, ...] = ()
|
freshenup/nodes.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Pure node-building: group outdated Items into the rows freshenup shows in fzf.
|
|
2
|
+
|
|
3
|
+
Both builders take injected lookups (`uses`, `deps_of`) for their brew/receipt queries, so the
|
|
4
|
+
grouping logic is unit-tested without any subprocess.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
|
|
11
|
+
from .models import Item, Kind, Node
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_formula_nodes(
|
|
15
|
+
outdated: list[Item],
|
|
16
|
+
leaves: set[str],
|
|
17
|
+
uses: Callable[[str], Iterable[str]],
|
|
18
|
+
) -> list[Node]:
|
|
19
|
+
"""Group outdated formulae under their leaf (installed-on-request) formula: an outdated leaf
|
|
20
|
+
is its own node; an outdated dependency folds under the leaves that require it (`uses` gives a
|
|
21
|
+
formula's recursive dependents)."""
|
|
22
|
+
selves: dict[str, Item] = {}
|
|
23
|
+
member_map: dict[str, list[Item]] = {}
|
|
24
|
+
for item in outdated:
|
|
25
|
+
if item.name in leaves:
|
|
26
|
+
selves[item.name] = item
|
|
27
|
+
else:
|
|
28
|
+
for dependent in uses(item.name):
|
|
29
|
+
if dependent in leaves:
|
|
30
|
+
member_map.setdefault(dependent, []).append(item)
|
|
31
|
+
names = sorted(selves.keys() | member_map.keys())
|
|
32
|
+
return [
|
|
33
|
+
Node(
|
|
34
|
+
kind=Kind.FORMULA,
|
|
35
|
+
name=name,
|
|
36
|
+
itself=selves.get(name),
|
|
37
|
+
members=tuple(member_map.get(name, ())),
|
|
38
|
+
)
|
|
39
|
+
for name in names
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def collapse_casks(
|
|
44
|
+
casks: list[Item],
|
|
45
|
+
deps_of: Callable[[str], Iterable[str]],
|
|
46
|
+
) -> list[Node]:
|
|
47
|
+
"""Fold an outdated cask under an outdated cask that depends on it (e.g. a version-band cask
|
|
48
|
+
under its wrapper) so the pair shows as one row. `deps_of` gives a cask's cask dependencies."""
|
|
49
|
+
by_name = {cask.name: cask for cask in casks}
|
|
50
|
+
outdated = by_name.keys()
|
|
51
|
+
deps = {cask.name: [d for d in deps_of(cask.name) if d in outdated] for cask in casks}
|
|
52
|
+
folded = {dep for dep_list in deps.values() for dep in dep_list}
|
|
53
|
+
return [
|
|
54
|
+
Node(
|
|
55
|
+
kind=Kind.CASK,
|
|
56
|
+
name=cask.name,
|
|
57
|
+
itself=cask,
|
|
58
|
+
members=tuple(by_name[d] for d in deps[cask.name]),
|
|
59
|
+
)
|
|
60
|
+
for cask in casks
|
|
61
|
+
if cask.name not in folded
|
|
62
|
+
]
|
freshenup/parse.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Pure parsers: raw ``brew``/``mas`` output and cask receipts → domain Items.
|
|
2
|
+
|
|
3
|
+
The only I/O-free layer — every function takes text and returns data, so the bug-prone parsing
|
|
4
|
+
(version extraction, JSON shapes) is unit-tested against fixtures without invoking brew.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
12
|
+
|
|
13
|
+
from .models import Item, Kind
|
|
14
|
+
|
|
15
|
+
# brew outdated --verbose lines: "name (1.0, 1.1) < 1.2" (formula), "name (1.0) != 1.2" (cask).
|
|
16
|
+
# A cask version can carry a comma-suffix that is part of the version (e.g. dotnet
|
|
17
|
+
# "8.0.422,8.0.28"); keep it and strip only a trailing build hash.
|
|
18
|
+
_TRAILING_HASH = re.compile(r",[0-9a-fA-F]{16,}$")
|
|
19
|
+
_OP = re.compile(r"^\s*(?:<|!=)\s*")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_brew_outdated(kind: Kind, text: str) -> list[Item]:
|
|
23
|
+
items: list[Item] = []
|
|
24
|
+
for line in text.splitlines():
|
|
25
|
+
open_paren = line.find(" (")
|
|
26
|
+
if open_paren < 0:
|
|
27
|
+
continue
|
|
28
|
+
rest = line[open_paren + 2 :]
|
|
29
|
+
close_paren = rest.find(")")
|
|
30
|
+
if close_paren < 0:
|
|
31
|
+
continue
|
|
32
|
+
installed = rest[:close_paren]
|
|
33
|
+
latest = _OP.sub("", rest[close_paren + 1 :])
|
|
34
|
+
current = installed.split(", ")[-1] # newest of the installed versions
|
|
35
|
+
items.append(
|
|
36
|
+
Item(
|
|
37
|
+
kind=kind,
|
|
38
|
+
name=line[:open_paren],
|
|
39
|
+
current=_TRAILING_HASH.sub("", current),
|
|
40
|
+
latest=_TRAILING_HASH.sub("", latest),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
return items
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _MasApp(BaseModel):
|
|
47
|
+
adam_id: int = Field(alias="adamID")
|
|
48
|
+
name: str
|
|
49
|
+
current: str = Field(alias="version", default="")
|
|
50
|
+
latest: str = Field(alias="newVersion")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_MAS_APPS = TypeAdapter(list[_MasApp])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_mas(text: str) -> list[Item]:
|
|
57
|
+
return [
|
|
58
|
+
Item(kind=Kind.MAS, name=a.name, current=a.current, latest=a.latest, mas_id=str(a.adam_id))
|
|
59
|
+
for a in _MAS_APPS.validate_json(text)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Dep(BaseModel):
|
|
64
|
+
full_name: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _RuntimeDeps(BaseModel):
|
|
68
|
+
cask: list[_Dep] = []
|
|
69
|
+
formula: list[_Dep] = []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Artifact(BaseModel):
|
|
73
|
+
# A cask's uninstall_artifacts is a heterogeneous list ({"app": [...]}, {"zap": [...]}, …);
|
|
74
|
+
# declaring only `app` (extra keys ignored) types the grab-bag without hand-digging.
|
|
75
|
+
app: list[str] = []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Receipt(BaseModel):
|
|
79
|
+
runtime_dependencies: _RuntimeDeps = Field(default_factory=_RuntimeDeps)
|
|
80
|
+
uninstall_artifacts: list[_Artifact] = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_receipt(text: str) -> tuple[list[str], list[str]]:
|
|
84
|
+
"""Return (dependency cask tokens, installed .app names) from a cask install receipt."""
|
|
85
|
+
receipt = _Receipt.model_validate_json(text)
|
|
86
|
+
deps = [dep.full_name.rsplit("/", 1)[-1] for dep in receipt.runtime_dependencies.cask]
|
|
87
|
+
apps = [name for artifact in receipt.uninstall_artifacts for name in artifact.app]
|
|
88
|
+
return deps, apps
|
freshenup/pick.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""fzf shell-out: present nodes for multi-select and render the preview pane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic import TypeAdapter
|
|
14
|
+
|
|
15
|
+
from .models import Node
|
|
16
|
+
|
|
17
|
+
_PAYLOAD = TypeAdapter(list[Node])
|
|
18
|
+
_ENV_PAYLOAD = "FRESHENUP_PAYLOAD"
|
|
19
|
+
_UNINSTALL = "%%UNINSTALL%%"
|
|
20
|
+
_HEADER = "enter=update ctrl-x=uninstall highlighted tab=toggle ctrl-t=invert"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_preview(node: Node) -> str:
|
|
24
|
+
header = node.name
|
|
25
|
+
if node.itself is not None:
|
|
26
|
+
header += f" {node.itself.current} → {node.itself.latest}"
|
|
27
|
+
header += f" {node.kind.label}"
|
|
28
|
+
members = [f" {m.name:<22} {m.current} → {m.latest}" for m in node.members]
|
|
29
|
+
return "\n".join([header, *members])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def preview(name: str) -> str:
|
|
33
|
+
"""Render the preview for ``name``, reading the node payload written by ``pick``."""
|
|
34
|
+
path = os.environ.get(_ENV_PAYLOAD)
|
|
35
|
+
if not path:
|
|
36
|
+
return name
|
|
37
|
+
nodes = _PAYLOAD.validate_json(Path(path).read_bytes())
|
|
38
|
+
return next((render_preview(n) for n in nodes if n.name == name), name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Selection:
|
|
43
|
+
nodes: list[Node]
|
|
44
|
+
uninstall: Node | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pick(nodes: list[Node]) -> Selection | None:
|
|
48
|
+
"""Show the fzf menu; return the chosen nodes (or a single node to uninstall), or None."""
|
|
49
|
+
by_name = {node.name: node for node in nodes}
|
|
50
|
+
with tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False) as tmp:
|
|
51
|
+
tmp.write(_PAYLOAD.dump_json(nodes))
|
|
52
|
+
payload = tmp.name
|
|
53
|
+
preview_cmd = f"{shlex.quote(sys.executable)} -m freshenup --preview {{}}"
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
[
|
|
57
|
+
"fzf",
|
|
58
|
+
"-m",
|
|
59
|
+
"--bind",
|
|
60
|
+
"load:select-all",
|
|
61
|
+
"--bind",
|
|
62
|
+
"ctrl-t:toggle-all",
|
|
63
|
+
"--bind",
|
|
64
|
+
f"ctrl-x:print({_UNINSTALL})+deselect-all+accept",
|
|
65
|
+
"--header",
|
|
66
|
+
_HEADER,
|
|
67
|
+
"--preview",
|
|
68
|
+
preview_cmd,
|
|
69
|
+
"--preview-window",
|
|
70
|
+
"right,55%",
|
|
71
|
+
],
|
|
72
|
+
input="\n".join(sorted(by_name)),
|
|
73
|
+
text=True,
|
|
74
|
+
stdout=subprocess.PIPE,
|
|
75
|
+
env={**os.environ, _ENV_PAYLOAD: payload},
|
|
76
|
+
)
|
|
77
|
+
finally:
|
|
78
|
+
os.unlink(payload)
|
|
79
|
+
if proc.returncode != 0:
|
|
80
|
+
return None
|
|
81
|
+
lines = [line for line in proc.stdout.splitlines() if line]
|
|
82
|
+
if not lines:
|
|
83
|
+
return None
|
|
84
|
+
if lines[0] == _UNINSTALL:
|
|
85
|
+
target = by_name.get(lines[1]) if len(lines) > 1 else None
|
|
86
|
+
return Selection(nodes=[], uninstall=target)
|
|
87
|
+
return Selection(nodes=[by_name[line] for line in lines if line in by_name], uninstall=None)
|
freshenup/system.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""I/O shell: run brew/mas, scan concurrently, and act on selections.
|
|
2
|
+
|
|
3
|
+
Thin by design — the logic lives in the pure parse/nodes functions; this module just shells out
|
|
4
|
+
and wires their injected lookups (``uses``, ``deps_of``, ``apps_of``, ``owner_of``) to real
|
|
5
|
+
queries. The one pure function here, ``blocked_casks``, takes its filesystem access injected so
|
|
6
|
+
it is unit-tested without touching ``/Applications``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import pwd
|
|
13
|
+
import subprocess
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from functools import cache
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .models import Item, Kind
|
|
21
|
+
from .parse import parse_brew_outdated, parse_mas, parse_receipt
|
|
22
|
+
|
|
23
|
+
_BREW = "brew"
|
|
24
|
+
_MAS = "mas"
|
|
25
|
+
_APPS = Path("/Applications")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def current_user() -> str:
|
|
29
|
+
return pwd.getpwuid(os.getuid()).pw_name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _capture(*args: str) -> str:
|
|
33
|
+
"""stdout of a command, or "" if it fails — scans tolerate a missing or erroring tool."""
|
|
34
|
+
proc = subprocess.run(args, capture_output=True, text=True)
|
|
35
|
+
return proc.stdout if proc.returncode == 0 else ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Scan:
|
|
40
|
+
formulae: list[Item]
|
|
41
|
+
casks: list[Item]
|
|
42
|
+
mas: list[Item]
|
|
43
|
+
leaves: set[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scan(*, refresh: bool, has_mas: bool) -> Scan:
|
|
47
|
+
if refresh:
|
|
48
|
+
print("Refreshing Homebrew…", flush=True)
|
|
49
|
+
subprocess.run([_BREW, "update"], stdout=subprocess.DEVNULL)
|
|
50
|
+
# brew's per-call startup is ~2.5s, so run the independent lookups concurrently.
|
|
51
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
52
|
+
formulae = pool.submit(_capture, _BREW, "outdated", "--formula", "--verbose")
|
|
53
|
+
casks = pool.submit(_capture, _BREW, "outdated", "--cask", "--verbose")
|
|
54
|
+
leaves = pool.submit(_capture, _BREW, "leaves", "--installed-on-request")
|
|
55
|
+
mas = pool.submit(_capture, _MAS, "outdated", "--json") if has_mas else None
|
|
56
|
+
mas_text = mas.result().strip() if mas is not None else ""
|
|
57
|
+
return Scan(
|
|
58
|
+
formulae=parse_brew_outdated(Kind.FORMULA, formulae.result()),
|
|
59
|
+
casks=parse_brew_outdated(Kind.CASK, casks.result()),
|
|
60
|
+
mas=parse_mas(mas_text) if mas_text else [],
|
|
61
|
+
leaves=set(leaves.result().split()),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def uses(name: str) -> list[str]:
|
|
66
|
+
"""Installed formulae that depend on ``name`` (recursively)."""
|
|
67
|
+
return _capture(_BREW, "uses", "--installed", "--recursive", name).split()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cache
|
|
71
|
+
def _caskroom() -> Path:
|
|
72
|
+
prefix = _capture(_BREW, "--prefix").strip() or "/opt/homebrew"
|
|
73
|
+
return Path(prefix) / "Caskroom"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _receipt(token: str) -> Path:
|
|
77
|
+
return _caskroom() / token / ".metadata" / "INSTALL_RECEIPT.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def deps_of(token: str) -> list[str]:
|
|
81
|
+
"""Cask tokens that cask ``token`` depends on (from its install receipt)."""
|
|
82
|
+
receipt = _receipt(token)
|
|
83
|
+
if not receipt.exists():
|
|
84
|
+
return []
|
|
85
|
+
deps, _apps = parse_receipt(receipt.read_text())
|
|
86
|
+
return deps
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def apps_of(token: str) -> list[str]:
|
|
90
|
+
"""Installed .app bundle names for cask ``token`` (from its install receipt)."""
|
|
91
|
+
receipt = _receipt(token)
|
|
92
|
+
if not receipt.exists():
|
|
93
|
+
return []
|
|
94
|
+
_deps, apps = parse_receipt(receipt.read_text())
|
|
95
|
+
return apps
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def owner_of(app: str) -> str | None:
|
|
99
|
+
"""Owning username of ``/Applications/<app>``, or None if absent/unknown."""
|
|
100
|
+
try:
|
|
101
|
+
return pwd.getpwuid((_APPS / app).stat().st_uid).pw_name
|
|
102
|
+
except FileNotFoundError, KeyError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def blocked_casks(
|
|
107
|
+
tokens: Iterable[str],
|
|
108
|
+
apps_of: Callable[[str], Iterable[str]],
|
|
109
|
+
owner_of: Callable[[str], str | None],
|
|
110
|
+
me: str,
|
|
111
|
+
) -> list[tuple[str, str]]:
|
|
112
|
+
"""Return (token, owner) for casks whose installed .app is owned by someone else (typically
|
|
113
|
+
root, after a vendor self-updater reinstalled it) — brew can't upgrade those without sudo."""
|
|
114
|
+
blocked: list[tuple[str, str]] = []
|
|
115
|
+
for token in tokens:
|
|
116
|
+
for app in apps_of(token):
|
|
117
|
+
owner = owner_of(app)
|
|
118
|
+
if owner is not None and owner != me:
|
|
119
|
+
blocked.append((token, owner))
|
|
120
|
+
break
|
|
121
|
+
return blocked
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def chown_cask(token: str, me: str) -> bool:
|
|
125
|
+
"""chown the cask's installed .app bundle(s) back to ``me`` (needs sudo). True on success."""
|
|
126
|
+
ok = True
|
|
127
|
+
for app in apps_of(token):
|
|
128
|
+
path = _APPS / app
|
|
129
|
+
if path.exists():
|
|
130
|
+
result = subprocess.run(["sudo", "chown", "-R", f"{me}:admin", str(path)])
|
|
131
|
+
ok = ok and result.returncode == 0
|
|
132
|
+
return ok
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def upgrade(kind: Kind, names: list[str]) -> None:
|
|
136
|
+
if not names:
|
|
137
|
+
return
|
|
138
|
+
if kind is Kind.MAS:
|
|
139
|
+
subprocess.run(["sudo", _MAS, "upgrade", *names])
|
|
140
|
+
else:
|
|
141
|
+
flag = "--cask" if kind is Kind.CASK else "--formula"
|
|
142
|
+
subprocess.run([_BREW, "upgrade", flag, *names])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def uninstall(kind: Kind, name: str, mas_id: str = "") -> None:
|
|
146
|
+
if kind is Kind.CASK:
|
|
147
|
+
subprocess.run([_BREW, "uninstall", "--cask", name])
|
|
148
|
+
elif kind is Kind.MAS:
|
|
149
|
+
if mas_id:
|
|
150
|
+
subprocess.run(["sudo", _MAS, "uninstall", mas_id])
|
|
151
|
+
elif subprocess.run([_BREW, "uninstall", "--formula", name]).returncode == 0:
|
|
152
|
+
subprocess.run([_BREW, "autoremove"])
|
|
@@ -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,13 @@
|
|
|
1
|
+
freshenup/__init__.py,sha256=30Rqdqu16ZwHbevW8xhQgAKeiMYNakOaAOrEUmKerpU,115
|
|
2
|
+
freshenup/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
freshenup/cli.py,sha256=KNcDdATAkiErwMzGR0bNwwEZV4-isfa0zt28aSVt9sY,4301
|
|
4
|
+
freshenup/models.py,sha256=JuYOZekXOTrnuYRMziyswCS-eeDwQ8MD3I0gAcWdHlg,984
|
|
5
|
+
freshenup/nodes.py,sha256=lhZjCPRDBgBsHMZb2IQMjN1S_-3RoqeoyX9Bz9oX534,2129
|
|
6
|
+
freshenup/parse.py,sha256=7klyEdn2vbyJx3e28FvwKYxjyn1oVK3aoq-IML3bDts,2866
|
|
7
|
+
freshenup/pick.py,sha256=P_O2TXpjpPXlnKZsrl0jFE_eLRizWu4DFdyi5ubxe68,2797
|
|
8
|
+
freshenup/system.py,sha256=NyTTuxRAGbRzX6Q2GmNS1MRQQxIaxroCgDfTs-ET0I4,5216
|
|
9
|
+
freshenup-3.0.0.dist-info/METADATA,sha256=sR-TLE1rSbZ4ZGd1MJzKRbup-moPaoBpvw6MhyUpE_Q,2375
|
|
10
|
+
freshenup-3.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
freshenup-3.0.0.dist-info/entry_points.txt,sha256=jP1A6NkyAdxtjs4flEhgbaK9A3YpYgXT9GFPJO09rRg,49
|
|
12
|
+
freshenup-3.0.0.dist-info/licenses/LICENSE,sha256=ZhLquyhQnoHk2SSFG0Mnxb9riWa5tYD2jXFufj_Crhw,1071
|
|
13
|
+
freshenup-3.0.0.dist-info/RECORD,,
|
|
@@ -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.
|