3 Commits

Author SHA1 Message Date
David Tomaschik
1d0a09c442 Bump Brewfile 2026-04-07 16:32:02 -07:00
David Tomaschik
37c765ae29 Update for bundles 2026-04-07 16:02:49 -07:00
David Tomaschik
41f8a49381 Remove missing brew entry 2026-04-07 14:53:41 -07:00
6 changed files with 213 additions and 87 deletions

View File

@@ -9,8 +9,9 @@ fi
UPDATE_SCRIPT="bin/macos/update_brewfile" UPDATE_SCRIPT="bin/macos/update_brewfile"
if [[ -x "$UPDATE_SCRIPT" ]]; then if [[ -x "$UPDATE_SCRIPT" ]]; then
# Run in dry-run mode and see if there's output echo "🔍 Checking Brewfile synchronization..."
DIFF_OUTPUT=$("$UPDATE_SCRIPT" --dry-run 2>/dev/null) # Run in dry-run mode with --add-only and see if there's output
DIFF_OUTPUT=$("$UPDATE_SCRIPT" --dry-run --add-only 2>/dev/null)
if [[ "$DIFF_OUTPUT" == *"Changes detected"* ]]; then if [[ "$DIFF_OUTPUT" == *"Changes detected"* ]]; then
echo "⚠️ Brewfile is out of sync with your installed packages." echo "⚠️ Brewfile is out of sync with your installed packages."
echo " Run '$UPDATE_SCRIPT' to synchronize it." echo " Run '$UPDATE_SCRIPT' to synchronize it."

View File

@@ -1,5 +1,6 @@
tap "dart-lang/dart" tap "dart-lang/dart"
tap "sass/sass" tap "sass/sass"
brew "ack" brew "ack"
brew "acme.sh" brew "acme.sh"
brew "age" brew "age"
@@ -7,14 +8,16 @@ brew "autoconf"
brew "automake" brew "automake"
brew "b2-tools" brew "b2-tools"
brew "bat" brew "bat"
brew "bazelisk"
brew "binwalk"
brew "cask" brew "cask"
brew "ccache" brew "ccache"
brew "certbot" brew "certbot"
brew "cmake" brew "cmake"
brew "colima" brew "colima"
brew "devcontainer" brew "devcontainer"
brew "difftastic"
brew "dfu-util" brew "dfu-util"
brew "difftastic"
brew "direnv" brew "direnv"
brew "duck" brew "duck"
brew "earthly" brew "earthly"
@@ -27,6 +30,7 @@ brew "git-lfs"
brew "gnupg" brew "gnupg"
brew "go" brew "go"
brew "gradle" brew "gradle"
brew "hf"
brew "htop" brew "htop"
brew "httpie" brew "httpie"
brew "huggingface-cli" brew "huggingface-cli"
@@ -40,11 +44,12 @@ brew "mosh"
brew "neovim" brew "neovim"
brew "ninja" brew "ninja"
brew "nmap" brew "nmap"
brew "protobuf" brew "ollama"
brew "p7zip" brew "p7zip"
brew "pipenv" brew "pipenv"
brew "pipx" brew "pipx"
brew "pkgconf" brew "pkgconf"
brew "protobuf"
brew "pwgen" brew "pwgen"
brew "pwntools" brew "pwntools"
brew "qemu" brew "qemu"
@@ -53,7 +58,8 @@ brew "ripgrep"
brew "ruby" brew "ruby"
brew "ruby@3.3" brew "ruby@3.3"
brew "rustup" brew "rustup"
brew "scroll-reverser" brew "sass/sass/migrator"
brew "sass/sass/sass"
brew "shellcheck" brew "shellcheck"
brew "smartmontools" brew "smartmontools"
brew "starship" brew "starship"
@@ -64,8 +70,7 @@ brew "wget"
brew "yt-dlp" brew "yt-dlp"
brew "zlib" brew "zlib"
brew "zsh-syntax-highlighting" brew "zsh-syntax-highlighting"
brew "sass/sass/migrator"
brew "sass/sass/sass"
cask "codeql" cask "codeql"
cask "cyberduck" cask "cyberduck"
cask "font-fira-code-nerd-font" cask "font-fira-code-nerd-font"
@@ -82,6 +87,7 @@ cask "iterm2"
cask "macfuse" cask "macfuse"
cask "meld" cask "meld"
cask "mitmproxy" cask "mitmproxy"
cask "processmonitor"
cask "raycast" cask "raycast"
cask "rectangle" cask "rectangle"
cask "scroll-reverser" cask "scroll-reverser"

View File

@@ -7,16 +7,69 @@ import sys
import argparse import argparse
import difflib import difflib
import tempfile
# Regex to match brew/cask/tap/mas lines # Regex to match brew/cask/tap/mas lines
PKG_RE = re.compile(r'^\s*(brew|cask|tap|mas)\s+["\']([^"\']+)["\'](.*)$') PKG_RE = re.compile(r'^\s*(brew|cask|tap|mas)\s+["\']([^"\']+)["\'](.*)$')
def colorize_diff(lines):
for line in lines:
if line.startswith('+') and not line.startswith('+++'):
yield f"\033[32m{line}\033[0m"
elif line.startswith('-') and not line.startswith('---'):
yield f"\033[31m{line}\033[0m"
elif line.startswith('^'):
yield f"\033[36m{line}\033[0m"
else:
yield line
class Entry:
def sort_key(self): raise NotImplementedError()
def to_lines(self): raise NotImplementedError()
class PackageEntry(Entry):
def __init__(self, pkg_type, name, options, comments=None):
self.pkg_type = pkg_type
self.name = name
self.options = options.strip()
self.comments = comments or []
def sort_key(self):
order = {'tap': 0, 'brew': 1, 'cask': 2, 'mas': 3}
return (order.get(self.pkg_type, 4), self.name)
def to_lines(self):
res = list(self.comments)
pkg_line = f'{self.pkg_type} "{self.name}"'
if self.options:
if not self.options.startswith(','):
pkg_line += ' '
pkg_line += self.options
res.append(pkg_line)
return res
class TextEntry(Entry):
def __init__(self, lines, is_header=True):
self.lines = lines
self.is_header = is_header
def sort_key(self):
# Header is -1, Trailing is 5 (after all package types 0-4)
return (-1 if self.is_header else 5, "")
def to_lines(self):
return self.lines
def get_repo_root(): def get_repo_root():
script_dir = os.path.dirname(os.path.realpath(__file__))
try: try:
root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'],
cwd=script_dir,
stderr=subprocess.STDOUT).decode().strip() stderr=subprocess.STDOUT).decode().strip()
return root return root
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return os.getcwd() # If not in a git repo, go up 2 levels from script_dir (bin/macos/)
return os.path.abspath(os.path.join(script_dir, '..', '..'))
def get_ignore_list(repo_root): def get_ignore_list(repo_root):
ignore = set() ignore = set()
@@ -34,11 +87,19 @@ def get_ignore_list(repo_root):
ignore.add(line) ignore.add(line)
return ignore return ignore
def get_current_packages(): def get_current_packages(args):
"""Runs brew bundle dump and returns lines.""" """Runs brew bundle dump and returns lines."""
env = os.environ.copy()
env["HOMEBREW_NO_AUTO_UPDATE"] = "1"
env["HOMEBREW_NO_INSTALL_CLEANUP"] = "1"
env["HOMEBREW_NO_ENV_HINTS"] = "1"
cmd = ['brew', 'bundle', 'dump', '--file=-']
if args.no_mas: cmd.append('--no-mas')
if args.no_vscode: cmd.append('--no-vscode')
try: try:
output = subprocess.check_output(['brew', 'bundle', 'dump', '--file=-'], output = subprocess.check_output(cmd, env=env, stderr=subprocess.DEVNULL).decode()
stderr=subprocess.DEVNULL).decode()
return output.splitlines() return output.splitlines()
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print("Error: 'brew bundle dump' failed. Is homebrew installed?", file=sys.stderr) print("Error: 'brew bundle dump' failed. Is homebrew installed?", file=sys.stderr)
@@ -48,9 +109,9 @@ def parse_brewfile(content):
""" """
Parses Brewfile content. Parses Brewfile content.
Returns: Returns:
- unconditional_lines: list of strings - entries: list of Entry objects
- conditional_pkgs: set of (type, name) - conditional_pkgs: set of (type, name)
- preserved_footer: string (everything from first conditional onwards) - footer: string (everything from first conditional onwards)
""" """
lines = content.splitlines() lines = content.splitlines()
conditional_pkgs = set() conditional_pkgs = set()
@@ -78,14 +139,35 @@ def parse_brewfile(content):
if stripped == 'end' or stripped.endswith('; end'): if stripped == 'end' or stripped.endswith('; end'):
in_conditional -= 1 in_conditional -= 1
if first_conditional_idx == -1: unconditional_lines = lines[:first_conditional_idx] if first_conditional_idx != -1 else lines
return lines, set(), "" footer = "\n".join(lines[first_conditional_idx:]) if first_conditional_idx != -1 else ""
unconditional_lines = lines[:first_conditional_idx] entries = []
footer = "\n".join(lines[first_conditional_idx:]) comment_buffer = []
return unconditional_lines, conditional_pkgs, footer first_pkg_seen = False
for line in unconditional_lines:
match = PKG_RE.match(line)
if match:
if not first_pkg_seen:
if comment_buffer:
entries.append(TextEntry(comment_buffer, is_header=True))
comment_buffer = []
first_pkg_seen = True
entries.append(PackageEntry(match.group(1), match.group(2), match.group(3), comment_buffer))
comment_buffer = []
else:
comment_buffer.append(line)
if comment_buffer:
entries.append(TextEntry(comment_buffer, is_header=False))
return entries, conditional_pkgs, footer
def main(args): def main(args):
if sys.platform != "darwin":
print(f"Warning: Running on {sys.platform}. Brewfile is primarily for macOS.", file=sys.stderr)
repo_root = get_repo_root() repo_root = get_repo_root()
brewfile_path = os.path.join(repo_root, 'Brewfile') brewfile_path = os.path.join(repo_root, 'Brewfile')
@@ -96,66 +178,84 @@ def main(args):
with open(brewfile_path) as f: with open(brewfile_path) as f:
old_content = f.read() old_content = f.read()
unconditional_lines, conditional_pkgs, footer = parse_brewfile(old_content) old_entries, conditional_pkgs, footer = parse_brewfile(old_content)
ignore_list = get_ignore_list(repo_root) ignore_list = get_ignore_list(repo_root)
dumped_lines = get_current_packages() old_pkg_map = {}
for e in old_entries:
if isinstance(e, PackageEntry):
key = (e.pkg_type, e.name)
if key not in old_pkg_map:
old_pkg_map[key] = e
new_unconditional_lines = [] dumped_lines = get_current_packages(args)
seen_pkgs = set() dumped_pkgs = []
for line in dumped_lines:
if args.add_only:
# First, keep existing unconditional packages
for line in unconditional_lines:
match = PKG_RE.match(line) match = PKG_RE.match(line)
if match: if match:
pkg_type, pkg_name = match.group(1), match.group(2) pkg_type, pkg_name, pkg_options = match.group(1), match.group(2), match.group(3)
if pkg_name in ignore_list or (pkg_type, pkg_name) in conditional_pkgs: if pkg_name in ignore_list or (pkg_type, pkg_name) in conditional_pkgs:
continue continue
new_unconditional_lines.append(line) dumped_pkgs.append(PackageEntry(pkg_type, pkg_name, pkg_options))
seen_pkgs.add((pkg_type, pkg_name))
elif line.strip() and not line.strip().startswith('#'):
# Keep other non-comment lines
new_unconditional_lines.append(line)
# Then, add new packages from dump new_entries = []
for line in dumped_lines: for e in old_entries:
match = PKG_RE.match(line) if isinstance(e, TextEntry) and e.is_header:
if match: new_entries.append(e)
pkg_type, pkg_name = match.group(1), match.group(2)
if (pkg_type, pkg_name) not in seen_pkgs and pkg_name not in ignore_list and (pkg_type, pkg_name) not in conditional_pkgs: seen_in_new = set()
new_unconditional_lines.append(line) added_count = 0
seen_pkgs.add((pkg_type, pkg_name)) removed_count = 0
merged_count = 0
if args.add_only:
for e in old_entries:
if isinstance(e, PackageEntry):
new_entries.append(e)
seen_in_new.add((e.pkg_type, e.name))
for d in dumped_pkgs:
if (d.pkg_type, d.name) not in seen_in_new:
new_entries.append(d)
seen_in_new.add((d.pkg_type, d.name))
added_count += 1
else: else:
for line in dumped_lines: for d in dumped_pkgs:
match = PKG_RE.match(line) key = (d.pkg_type, d.name)
if match: if key in seen_in_new: continue
pkg_type, pkg_name = match.group(1), match.group(2) if key in old_pkg_map:
if pkg_name in ignore_list: merged = old_pkg_map[key]
continue if not merged.options:
if (pkg_type, pkg_name) in conditional_pkgs: merged.options = d.options
continue new_entries.append(merged)
if (pkg_type, pkg_name) in seen_pkgs: merged_count += 1
continue else:
seen_pkgs.add((pkg_type, pkg_name)) new_entries.append(d)
added_count += 1
seen_in_new.add(key)
# If it's not a package line (e.g. comment from dump), we can skip or keep # Check for removals
if line.strip(): for key in old_pkg_map:
new_unconditional_lines.append(line) if key not in seen_in_new:
removed_count += 1
# Sort lines by type (tap, brew, cask, mas) then name for e in old_entries:
def sort_key(line): if isinstance(e, TextEntry) and not e.is_header:
match = PKG_RE.match(line) new_entries.append(e)
if not match: return (4, line)
order = {'tap': 0, 'brew': 1, 'cask': 2, 'mas': 3}
return (order.get(match.group(1), 4), match.group(2))
new_unconditional_lines.sort(key=sort_key) new_entries.sort(key=lambda x: x.sort_key())
# Build new content output_lines = []
new_content = "\n".join(new_unconditional_lines) last_type = None
for e in new_entries:
if isinstance(e, PackageEntry):
if last_type and e.pkg_type != last_type:
output_lines.append("")
last_type = e.pkg_type
output_lines.extend(e.to_lines())
new_content = "\n".join(output_lines)
if footer: if footer:
if new_unconditional_lines: if output_lines and output_lines[-1].strip():
new_content += "\n\n" new_content += "\n\n"
new_content += footer.strip() + "\n" new_content += footer.strip() + "\n"
else: else:
@@ -166,21 +266,33 @@ def main(args):
else: else:
if args.dry_run: if args.dry_run:
print("Changes detected (dry run):") print("Changes detected (dry run):")
diff = difflib.unified_diff( diff = list(difflib.unified_diff(
old_content.splitlines(keepends=True), old_content.splitlines(keepends=True),
new_content.splitlines(keepends=True), new_content.splitlines(keepends=True),
fromfile='Brewfile (original)', fromfile='Brewfile (original)',
tofile='Brewfile (new)' tofile='Brewfile (new)'
) ))
if sys.stdout.isatty():
sys.stdout.writelines(colorize_diff(diff))
else:
sys.stdout.writelines(diff) sys.stdout.writelines(diff)
else: else:
with open(brewfile_path, 'w') as f: dir_name = os.path.dirname(brewfile_path)
f.write(new_content) with tempfile.NamedTemporaryFile('w', dir=dir_name, delete=False) as tf:
tf.write(new_content)
tempname = tf.name
os.replace(tempname, brewfile_path)
print("Brewfile updated.") print("Brewfile updated.")
if args.verbose:
print(f"Summary: {added_count} added, {removed_count} removed, {merged_count} kept/merged.")
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Update Brewfile while preserving conditionals.") parser = argparse.ArgumentParser(description="Update Brewfile while preserving conditionals.")
parser.add_argument("--dry-run", action="store_true", help="Show changes without applying them.") parser.add_argument("--dry-run", action="store_true", help="Show changes without applying them.")
parser.add_argument("--add-only", action="store_true", help="Only add missing entries, do not remove existing ones.") parser.add_argument("--add-only", action="store_true", help="Only add missing entries, do not remove existing ones.")
parser.add_argument("--verbose", "-v", action="store_true", help="Print summary of changes.")
parser.add_argument("--no-mas", action="store_true", help="Do not include Mac App Store apps.")
parser.add_argument("--no-vscode", action="store_true", help="Do not include VSCode extensions.")
args = parser.parse_args() args = parser.parse_args()
main(args) main(args)

View File

@@ -30,5 +30,8 @@ if status --is-interactive
install_fisher install_fisher
end end
# Want this at the bottom to put this path first # Want these at the bottom to put them first in PATH
fish_add_path --move --path {$HOME}/bin fish_add_path --move --path {$HOME}/bin
if test (uname) = "Darwin"
fish_add_path --move --path {$HOME}/bin/macos
end

View File

@@ -189,6 +189,7 @@ if [ "$(uname)" = "Darwin" ] ; then
# Using id -u for better POSIX compatibility than $UID # Using id -u for better POSIX compatibility than $UID
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$TMPDIR/runtime-$(id -u)}" export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$TMPDIR/runtime-$(id -u)}"
export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
export PATH="${HOME}/bin/macos:${PATH}"
fi fi
if test -e "$HOME/.localenv"; then if test -e "$HOME/.localenv"; then

View File

@@ -271,8 +271,11 @@ if [ -x /usr/bin/ack-grep ] ; then
alias ack='/usr/bin/ack-grep' alias ack='/usr/bin/ack-grep'
fi fi
# I want this first always # I want these first always
PATH="${HOME}/bin:${PATH}" PATH="${HOME}/bin:${PATH}"
if [[ "$(uname)" == "Darwin" ]]; then
PATH="${HOME}/bin/macos:${PATH}"
fi
# Load any local settings # Load any local settings
source_if_existing $HOME/.zshrc.local source_if_existing $HOME/.zshrc.local