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 ADDED
@@ -0,0 +1,3 @@
1
+ """freshenup — pick outdated Homebrew and Mac App Store items to upgrade or uninstall."""
2
+
3
+ __version__ = "3.0.0"
freshenup/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ freshenup = freshenup.cli:main
@@ -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.