Last active
May 10, 2025 23:45
-
-
Save gastonmorixe/9d3b9d6588442313272c11061f702a6e to your computer and use it in GitHub Desktop.
Homebrew helper to try to check outdated casks towards the real installed version (for macOS using mdls kMDItemVersion)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
VERSION = '0.0.32' | |
# ╔════════════════════════════════════════════════════════╗ | |
# ║ Homebrew Cask Real Upgrade Helper (macOS) ║ | |
# ║ ----------------------------------------- ║ | |
# ║ Author: Gaston Morixe <[email protected]> ║ | |
# ║ Vibe Coded with: OpenAI o3 - Last Update May 10 2025 ║ | |
# ║ ---------------------------------------------------- ║ | |
# ║ Usage: `$ brew ruby this-file.rb` without extra gems ║ | |
# ╚════════════════════════════════════════════════════════╝ | |
# | |
# BUGS / TODOS: | |
# ──────────────────────────────────────────────────────────────────────────── | |
# - [ ] It doesn't checks the terminal / window width nor hight so it must | |
# fit by the user adjusting zoom in/out for now. It doesn't even | |
# auto-scroll yet. | |
# | |
# FEATURES | |
# ──────────────────────────────────────────────────────────────────────────── | |
# • Dynamically‑sized, perfectly aligned ANSI table | |
# • Integrated TUI selector – ↑/↓ to move, Space to toggle [x], Enter to exit | |
# • Default‑selects rows that *really* need upgrading | |
# • Prints (it never executes! you do it by copy-paste: | |
# $ brew upgrade --cask --greedy {{ selected casks }} | |
# • Verbose scanning log with colour‑coded [i] / [skip] / [error] | |
# • 100 % standalone – copy, `brew ruby`, enjoy | |
# | |
# NOTE | |
# --------------------------------------------------------------------------- | |
# Homebrew runs in a restricted “portable‑ruby” that lacks the gem ecosystem. | |
# This script therefore avoids *all* third‑party gems. It uses only: | |
# • `io/console` – raw‑mode keyboard handling | |
# • `open3` – to query `mdls` for real app bundle versions | |
# • ANSI escapes – for colours & cursor control | |
# | |
# Author : @gastonmorixe | |
# License: MIT 2025 | |
# ═════════════════════════════════════════════════════════════════════════════ | |
require 'open3' | |
require 'io/console' | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ ANSI – tiny utility for colour and screen control ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
module ANSI | |
# Map *semantic* colour / style names to ANSI codes | |
MAP = { | |
# foreground | |
green: 32, | |
yellow: 33, | |
red: 31, | |
cyan: 36, | |
white: 97, | |
bold: 1, | |
underline: 4, | |
# background | |
bg_green: 42, | |
bg_yellow: 43, | |
bg_red: 41, | |
bg_cyan: 46, | |
bg_white: 107 | |
}.freeze | |
# colourises *str* with given *styles* (symbols). No‑op when not a TTY. | |
# @param str [String] string to colourise | |
# @param styles [Symbol] one or more style symbols | |
# @return [String] string with ANSI escape codes | |
def self.col(str, *styles) | |
return str unless STDOUT.tty? | |
codes = styles.map { |s| MAP[s] }.compact.join(';') | |
"\e[#{codes}m#{str}\e[0m" | |
end | |
# Clear screen & reset cursor (classic DOS “CLS”). | |
def self.clear | |
print "\e[H\e[2J" | |
end | |
end | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ VERSION HELPERS ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
# Homebrew encodes build metadata after the first comma. macOS apps report | |
# only the human‑readable *core* version – we strip anything beyond that. | |
def sanitize_version_string(str) | |
return '' if str.nil? || str.empty? | |
core = str.split(',').first # “5.0.4,50004…” → “5.0.4” | |
core.gsub!(/[^0-9.]+/, '.') # replace non digits with dot | |
core.gsub!(/\.{2,}/, '.') # squeeze duplicate dots | |
core.gsub!(/\A\.|\.\z/, '') # trim leading / trailing dot | |
core | |
end | |
# Safe Gem::Version parsing – always returns a valid object. | |
def parse_version(str) | |
Gem::Version.new(sanitize_version_string(str).yield_self { |s| s.empty? ? '0' : s }) | |
rescue ArgumentError | |
Gem::Version.new('0') | |
end | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ SYSTEM HELPER – fetch real version from .app bundle ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
def mdls_version(app_path) | |
return unless app_path&.exist? | |
out, status = Open3.capture2('mdls', '-raw', '-name', 'kMDItemVersion', app_path.to_s) | |
status.success? ? sanitize_version_string(out.strip) : nil | |
end | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ RESULT STRUCT ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
Result = Struct.new( | |
:token, # cask token (string) | |
:installed?, | |
:real, # Gem::Version of actual bundle on disk | |
:brew_db, # Gem::Version from Homebrew receipt | |
:latest, # Gem::Version declared in current cask | |
:upgrade?, | |
:reason, # explanatory string or nil | |
keyword_init: true | |
) | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ COMPARISON LOGIC ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
def compare_version(cask) | |
brew_db = parse_version(cask.installed_version.to_s) | |
latest = parse_version(cask.version.to_s) | |
real_versions = cask.artifacts.grep(Cask::Artifact::App) | |
.map(&:target) | |
.filter_map { |p| mdls_version(p) } | |
.uniq | |
.map { |v| parse_version(v) } | |
installed = !real_versions.empty? | |
unless installed | |
return Result.new(token: cask.token, | |
installed?: false, | |
brew_db: brew_db, | |
latest: latest, | |
upgrade?: false, | |
reason: 'bundle‑missing') | |
end | |
raise "Multiple .app bundles for #{cask.token}" if real_versions.size > 1 | |
real = real_versions.first | |
reason = if real.prerelease? | |
'installed‑prerelease' | |
elsif latest.prerelease? | |
'latest‑prerelease' | |
end | |
upgrade = reason.nil? && real < latest | |
Result.new(token: cask.token, | |
installed?: true, | |
real: real, | |
brew_db: brew_db, | |
latest: latest, | |
upgrade?: upgrade, | |
reason: reason) | |
end | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ DYNAMIC TABLE RENDERING ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
TABLE_HEADERS = %w[Sel Cask Found Real Brew‑DB Latest Upgrade Reason].freeze | |
# Compute minimal column widths based on *string* lengths of headers + rows. | |
COLUMNS_PADDING = 3 | |
def column_widths(results) | |
widths = Array.new(TABLE_HEADERS.size, 0) | |
rows_plain = results.map do |r| | |
[ | |
'[ ]', | |
r.token, | |
(r.installed? ? 'YES' : 'NO'), | |
(r.real || '-').to_s, | |
(r.brew_db || '-').to_s, | |
(r.latest || '-').to_s, | |
(r.upgrade? ? 'YES' : 'NO'), | |
r.reason || '' | |
] | |
end | |
rows_plain << TABLE_HEADERS | |
rows_plain.each do |row| | |
row.each_with_index do |cell, idx| | |
widths[idx] = [widths[idx], cell.length].max | |
end | |
end | |
widths.map.with_index do |row_width, idx| | |
padding = COLUMNS_PADDING * (idx > 0 ? 1 : 0) | |
new_width = row_width + padding | |
new_width | |
end | |
end | |
# Pad *visible* string to `width` using spaces – preserves colour codes. | |
def pad(cell_plain, cell_coloured, width) | |
padding = ' ' * (width - cell_plain.length) | |
"#{cell_coloured}#{padding}" | |
end | |
# Build a single coloured row string. | |
def build_row(r, selected, pointer, widths) | |
cells_plain = [ | |
selected ? '[x]' : '[ ]', | |
r.token, | |
(r.installed? ? 'YES' : 'NO'), | |
(r.real || '-').to_s, | |
(r.brew_db || '-').to_s, | |
(r.latest || '-').to_s, | |
(r.upgrade? ? 'YES' : 'NO'), | |
r.reason || '' | |
] | |
cells_coloured = [ | |
selected ? ANSI.col('[x]', :green, :bold) : '[ ]', | |
ANSI.col(r.token, if pointer | |
:white | |
else | |
r.upgrade? ? :green : :cyan | |
end), | |
ANSI.col(cells_plain[2], r.installed? ? nil : :red), | |
cells_plain[3], | |
cells_plain[4], | |
cells_plain[5], | |
if r.upgrade? | |
ANSI.col('YES', :bg_green, :bold, :white) | |
else | |
ANSI.col('NO', r.reason.nil? ? :yellow : :red) | |
end, | |
ANSI.col(cells_plain[7], :red) | |
] | |
row = cells_plain.each_index.map do |idx| | |
pad(cells_plain[idx], cells_coloured[idx], widths[idx]) | |
end.join(' ') | |
pointer ? ANSI.col(row, :white, :bg_cyan) : row | |
end | |
# Render header + all rows. | |
def render_table(results, selected_set, pointer_idx) | |
widths = column_widths(results) | |
header = TABLE_HEADERS.each_index.map do |idx| | |
pad(TABLE_HEADERS[idx], ANSI.col(TABLE_HEADERS[idx], :underline, :bold), widths[idx]) | |
end.join(' ') | |
puts "\r#{header}" | |
puts "\r" + ('-' * widths.sum) | |
results.each_with_index do |r, idx| | |
puts "\r#{build_row(r, selected_set.include?(r.token), idx == pointer_idx, widths)}\r" | |
end | |
end | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ INTERACTIVE SELECTOR ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
# Returns array of *selected* cask tokens. If STDIN is not a TTY (e.g. piped), | |
# it auto‑selects rows that need an upgrade and returns silently. | |
def interactive_select(results) | |
# Default selection – rows flagged for upgrade | |
selected = results.select(&:upgrade?).map { |r| [r.token, true] }.to_h # index of highlighted row | |
pointer = 0 | |
# Non‑interactive fallback | |
return selected.keys unless STDIN.tty? | |
STDIN.raw do |stdin| | |
loop do | |
ANSI.clear | |
puts ANSI.col("============== Homebrew Cask Real Upgrade Helper v#{VERSION} ==============", :white, | |
:bold) | |
puts ANSI.col("\r▲/▼ move • SPACE toggle • ENTER confirm • CTRL‑C abort\r", :white, :bold) | |
render_table(results, selected, pointer) | |
char = stdin.getch | |
case char | |
when "\e" # start of escape sequence | |
next_char = begin | |
stdin.read_nonblock(2) | |
rescue StandardError | |
nil | |
end # read rest of escape seq | |
case next_char | |
when '[A' # up | |
pointer = (pointer - 1) % results.length | |
when '[B' # down | |
pointer = (pointer + 1) % results.length | |
end | |
when ' ' # toggle selection on current row | |
token = results[pointer].token | |
selected.key?(token) ? selected.delete(token) : selected[token] = true | |
when "\r", "\n" # Enter → finish | |
break | |
when "\u0003" # Ctrl‑C | |
puts "\nAborted by user." | |
exit 1 | |
end | |
end | |
end | |
selected.keys | |
end | |
# ╔════════════════════════════════════════════════════════════════════════════╗ | |
# ║ MAIN ║ | |
# ╚════════════════════════════════════════════════════════════════════════════╝ | |
def run | |
# Scan casks and build result objects | |
results = Cask::Caskroom.casks.map do |cask| | |
puts ANSI.col("\r[i] Checking #{cask.token}", :cyan) | |
unless cask.outdated?(greedy: true) | |
puts ANSI.col("\r[skip] #{cask.token} is current", :yellow) | |
next | |
end | |
compare_version(cask) | |
rescue StandardError => e | |
warn ANSI.col("\r[error] #{cask.token}: #{e.message}", :red) | |
nil | |
end.compact | |
if results.empty? | |
puts ANSI.col("\r🎉 All casks are already up‑to‑date.", :green) | |
return | |
end | |
selected_tokens = interactive_select(results) | |
if selected_tokens.empty? | |
puts ANSI.col("\rNo casks selected – nothing to upgrade.", :yellow) | |
else | |
puts ANSI.col("\r\nRun this command to upgrade:", :white, :bold) | |
puts ANSI.col("\r$ brew upgrade --cask --greedy #{selected_tokens.join(' ')}", :green) | |
end | |
end | |
run if $PROGRAM_NAME == __FILE__ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.