Skip to content

feat: add one-command "update everything" script to the installer #8

@catinspace-au

Description

@catinspace-au

Summary

Add a one-command "update everything" script to the hyperi-developer installer,
so a freshly-provisioned machine has a single command (and an optional
clickable app) to update everything the installer set up.

There is no single package manager on macOS, so "update the lot" means running
several tools in sequence -- the macOS analogue of the Linux
apt update && apt upgrade && flatpak update habit.

Why

  • The installer lays down Homebrew formulae+casks, uv tools, rustup toolchains,
    and the Claude Code CLI -- each updates via a different command. Users
    currently have to remember and run all of them.
  • A maintained script that tracks the installer's own tool set keeps "update"
    in lockstep with "install".

Proposed behaviour

  • One command updates: Homebrew (formulae + casks, incl. --cask --greedy,
    autoremove, cleanup), uv tools, rustup toolchains, Claude Code CLI, and
    macOS system/security updates (softwareupdate, last, may need a restart).
  • Self-guarding: every section is command -v-guarded, so a missing tool is
    skipped (printed, not fatal). Safe to run where only some tools are present.
  • Unattended-friendly: HOMEBREW_NO_ENV_HINTS=1, softwareupdate --agree-to-license. The only interactive step is the single sudo password
    for macOS updates.
  • Per-section failures are recorded and reported in a summary; one failing
    section never aborts the rest.
  • Optional --install: builds a double-clickable launcher app (via
    osacompile, no deps) that runs the script in a Terminal window.

Notes for merging into the installer

  • Keep the covered tool list in sync with whatever the installer provisions
    (e.g. if Node/JFrog/Linear CLI land in a profile, add their updaters).
  • gcloud is the brew cask gcloud-cli, so brew upgrade --cask already
    covers it -- do NOT also run gcloud components update (that forks the
    version brew tracks from the one on disk).
  • The Homebrew section sets HOMEBREW_CASK_OPTS=--no-quarantine to avoid the
    Gatekeeper "downloaded from the internet" prompt on freshly-updated casks.
  • Still requires zsh itself (used for the coloured output and arrays).

Reference implementation

Working script in use today on macOS -- a drop-in starting point. Adjust the
tool list to match the installer's profiles before merging:

#!/usr/bin/env zsh
#
# update-mac.sh — update everything on this machine in one command:
#   - macOS system + security updates (softwareupdate)
#   - Homebrew formulae and casks (apps, CLIs, subsystems — incl. aws, gh,
#     az, kubectl, helm, terraform, vault, gcloud-cli, ...)
#   - uv tools (ansible, hyperi-ci), rustup toolchains
#   - Claude Code CLI (self-installed under ~/.local)
#
# Each section is independent and self-guarding: any tool that isn't
# installed is skipped (not an error), and if a step fails the script
# keeps going and reports it in the summary at the end. Safe to run on a
# machine that has only some of these tools. Run with:  ~/update-mac.sh
# (will prompt once for your password for the macOS system updates).
#
# Run `~/update-mac.sh --install` once to drop a double-clickable
# "Update Mac" app into /Applications.

set -u
emulate -L zsh

# --- pretty output --------------------------------------------------------
autoload -Uz colors && colors
typeset -a FAILED
section() { print -P "\n%F{cyan}==> $1%f"; }
ok()      { print -P "%F{green}   ✓ $1%f"; }
warn()    { print -P "%F{yellow}   ! $1%f"; }
# run "<label>" cmd args...  — runs a step, records failure but never aborts
run() {
  local label=$1; shift
  if "$@"; then ok "$label"; else warn "$label FAILED"; FAILED+=("$label"); fi
}
have() { command -v "$1" >/dev/null 2>&1; }

# --- self-resolve: absolute path to this script (symlinks resolved) -------
SELF=${${(%):-%x}:A}

usage() {
  cat <<EOF
update-mac.sh — update Homebrew, uv, rustup, Claude Code and macOS in one go.

Usage:
  update-mac.sh            Run all updates now.
  update-mac.sh --install  Create a clickable "Update Mac" app in /Applications.
  update-mac.sh --help     Show this help.
EOF
}

# Build a double-clickable .app that opens Terminal and runs this script.
# Uses osacompile (built into macOS) — no dependencies. The app tells Terminal
# to `exec` this script, so when the run finishes the shell exits and (if your
# Terminal profile is set to "close if the shell exited cleanly") the window
# closes by itself; a failed run leaves it open to read.
install_app() {
  if ! have osacompile; then
    warn "osacompile not found (not macOS?) — cannot build the app"
    return 1
  fi
  local app="/Applications/Update Mac.app"
  local icon_store="$HOME/.local/share/update-mac/AppIcon.icns"
  local legacy_icon="/Applications/Update All.app/Contents/Resources/AppIcon.icns"
  section "Installing clickable app"

  # Keep the custom icon outside any app bundle so re-installs (or deleting
  # the old "Update All.app") never lose it. Migrate it from the legacy app
  # the first time, if we don't already have a stored copy.
  if [[ ! -f "$icon_store" && -f "$legacy_icon" ]]; then
    mkdir -p "${icon_store:h}"
    cp "$legacy_icon" "$icon_store" && ok "kept custom icon at $icon_store"
  fi

  rm -rf "$app"
  local tmp; tmp=$(mktemp -d)
  cat > "$tmp/run.applescript" <<EOF
tell application "Terminal"
	activate
	do script "exec " & quoted form of "$SELF"
end tell
EOF
  if ! osacompile -o "$app" "$tmp/run.applescript"; then
    warn "failed to build the app (no write access to /Applications?)"
    rm -rf "$tmp"
    return 1
  fi
  rm -rf "$tmp"

  # Apply the custom icon. osacompile apps read Contents/Resources/applet.icns;
  # remove the bundled Assets.car so it can't override our icns.
  if [[ -f "$icon_store" ]]; then
    cp "$icon_store" "$app/Contents/Resources/applet.icns"
    rm -f "$app/Contents/Resources/Assets.car"
  fi

  # Editing the bundle invalidates osacompile's signature: re-sign ad-hoc and
  # nudge the icon cache so Finder/Dock pick up the new icon.
  have codesign && codesign --force --deep --sign - "$app" >/dev/null 2>&1
  touch "$app"

  ok "created $app"
  print -P "%F{green}   Launch it from Spotlight/Launchpad as 'Update Mac', or drag it to the Dock.%f"
  print -P "%F{yellow}   First launch asks permission to control Terminal — allow it once.%f"
}

# --- argument handling ----------------------------------------------------
case "${1:-}" in
  --install)  install_app; exit $? ;;
  -h|--help)  usage; exit 0 ;;
  "")         ;;  # no args: fall through and run all updates
  *)          print -u2 "update-mac.sh: unknown option '$1'"; usage; exit 2 ;;
esac

print -P "%F{magenta}╔════════════════════════════════════════╗%f"
print -P "%F{magenta}║   Updating everything on $(scutil --get ComputerName 2>/dev/null || hostname)%f"
print -P "%F{magenta}╚════════════════════════════════════════╝%f"

# --- Homebrew: formulae + casks (the bulk of your apps & subsystems) ------
if have brew; then
  section "Homebrew"
  # Don't quarantine freshly-downloaded casks. Without this, every updated
  # app triggers the macOS Gatekeeper prompt ("<App> is an app downloaded
  # from the Internet, are you sure you want to open it?") on first launch.
  export HOMEBREW_CASK_OPTS="--no-quarantine"
  # Run unattended: suppress brew's env-var hints so output stays clean.
  export HOMEBREW_NO_ENV_HINTS=1
  run "brew update"       brew update
  run "brew upgrade"      brew upgrade            # formulae + outdated casks
  # Casks that self-update report no version to brew; --greedy catches them too.
  run "brew upgrade --cask --greedy" brew upgrade --cask --greedy
  run "brew autoremove"   brew autoremove         # drop now-unused dependencies
  run "brew cleanup"      brew cleanup --prune=all # delete old downloads/versions
else
  warn "Homebrew not found — skipping"
fi

# --- uv: CLI tools installed via `uv tool install` ------------------------
if have uv; then
  section "uv tools"
  run "uv tool upgrade --all" uv tool upgrade --all
else
  warn "uv not found — skipping"
fi

# --- rustup: Rust toolchains (rustup itself is updated by brew) -----------
if have rustup; then
  section "rustup toolchains"
  run "rustup update" rustup update
else
  warn "rustup not found — skipping"
fi

# --- Claude Code CLI: self-installed under ~/.local, not brew-managed ------
if have claude; then
  section "Claude Code CLI"
  run "claude update" claude update
else
  warn "claude not found — skipping"
fi

# NOTE: gcloud is intentionally NOT updated here. It's installed as the brew
# cask 'gcloud-cli', so `brew upgrade --cask` above already updates it.
# Running `gcloud components update` as well would fork the version brew
# tracks from the one on disk.

# --- macOS: Apple system + security updates (last; may need a restart) ----
if have softwareupdate; then
  section "macOS system updates (Apple)"
  warn "may prompt for your password and could require a restart"
  run "softwareupdate" sudo softwareupdate --install --all --agree-to-license
else
  warn "softwareupdate not found (not macOS?) — skipping"
fi

# --- summary --------------------------------------------------------------
print -P "\n%F{magenta}──────────── summary ────────────%f"
if (( ${#FAILED} == 0 )); then
  print -P "%F{green}All updates completed successfully.%f"
else
  print -P "%F{yellow}Completed with ${#FAILED} issue(s):%f"
  for f in $FAILED; do print -P "%F{yellow}   - $f%f"; done
  exit 1
fi

Filed from a working copy at ~/update-mac.sh. The companion doc
(~/update-mac.md) explains the per-section design rationale.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions