Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save gastonmorixe/9d3b9d6588442313272c11061f702a6e to your computer and use it in GitHub Desktop.
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)
#!/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__
@gastonmorixe
Copy link
Author

gastonmorixe commented May 10, 2025

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment