If you manage Homebrew with Workbrew, your users end up in an odd spot. IT controls the policy (pins, forbidden formulae and casks, configuration), but the person at the keyboard is the one who notices that something is out of date. They have no good way to see what IT has decided, and IT has no good way to say “go ahead and update.”
I wanted a single Self Service item that answers four questions for the person sitting at the Mac:
- What is pinned on this machine?
- What has IT forbidden or configured?
- What has an update waiting?
- Do any of my installed packages have known vulnerabilities?
Then it should let them do something about it. The result is workbrew_dashboard.sh, a zsh script scaffolded with Shikomi and displayed with swiftDialog. It started as v1.0.0 on July 22 and has been through a handful of patch releases since.
Where Each Piece of Data Comes From
Most of the design is one decision repeated four times: should this fact come from the machine or from the Workbrew API?
Pins and outdated versions come from the machine. The Workbrew API only exposes the fleet-wide latest-available version of a package. There is no per-device “currently installed version” field. And a Workbrew pin is a real brew pin underneath, so brew list --pinned is ground truth. Both go through the setuid-root brew binary that Workbrew installs:
function gather_local_pins() {
local line
while IFS= read -r line; do
[[ -n "$line" ]] && PINNED_NAMES+=("$line")
done < <("$BREW_BINARY" list --pinned 2>/dev/null)
log "Locally pinned formulae: ${PINNED_NAMES[*]:-<none>}"
}
Outdated packages come from brew outdated --json=v2, run once for formulae and once for casks.
Vulnerabilities and IT policy come from the API. There is no local equivalent for either. Forbidden formulae, forbidden casks and other HOMEBREW_* configuration come from brew_configurations.json; known CVEs come from vulnerabilities.json.
Both endpoints return fleet-wide data, and as far as I could tell from the API spec there is no per-device or per-group filter. So the script filters client-side: vulnerabilities against this Mac’s serial number, and configuration entries against the device groups this Mac belongs to (resolved from devices.json).
Degrading Gracefully
A Self Service item runs on machines in all kinds of states, so the script treats a missing piece as a reason to show less, not to fail.
The API helper returns distinct codes for “forbidden by plan” and “actually broke”:
if [[ "$http_code" == "403" ]]; then
log_warn "Workbrew API GET ${api_path} returned 403 (not available on this workspace's plan) - skipping"
return 2
fi
if [[ ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
log_error "Workbrew API GET ${api_path} failed (HTTP ${http_code})"
return 1
fi
The vulnerabilities endpoint needs a paid Workbrew plan and returns a 403 on the free tier. In that case the vulnerability section is simply omitted and everything else still renders. The same approach covers a missing API key or workspace name, Workbrew not being installed on the Mac, and a Mac that isn’t enrolled in the configured workspace. Each gets a plain-language dialog or a partially populated dashboard instead of a stack trace.
The Dashboard
One swiftDialog window holds the summary message and a checkbox list, one row per outdated package. Everything is checked by default, except pinned packages, which are shown disabled and labeled as held:
if is_pinned "$name"; then
label+=" (pinned - held at current version)"
checked="false"
disabled="true"
else
checked="true"
disabled="false"
fi
Packages with a known vulnerability get a warning triangle instead of the standard icon. I originally tried putting the CVE text in each row’s status field. At the default dialog width it got truncated, and a fail status draws a red X that reads as a close button rather than a security warning. The CVE details moved into the message body as their own section, and the row icon does the flagging.
When the user clicks Update Selected, the script runs brew upgrade locally for each checked package, with a live progress list driven through swiftDialog’s command file:
echo "listitem: index: ${dialog_index}, status: wait, statustext: Upgrading ..." >> "$dialog_command_file"
"$BREW_BINARY" upgrade "--${kind}" "$name" > "/tmp/workbrew_dashboard_upgrade_${safe_name}.log" 2>&1
Each item flips to success or fail as it finishes, and the final line reports how many were updated, skipped or failed. Pinned packages are reported as skipped rather than attempted. Homebrew would refuse them anyway, but a red failure for something that was never going to run is confusing.
Why Upgrades Run Locally
Workbrew has an endpoint for creating brew commands remotely, so it would be natural to have the dashboard queue upgrades through the API. I don’t, because in my testing for an earlier patching integration, commands created through the API never actually executed. Running brew upgrade locally is boring and it works. That also means a user is never left wondering whether the update they clicked is happening somewhere else.
npm and VS Code Extensions
Workbrew’s vulnerability feed grew to report more than formulae and casks. Entries now carry a package_type, and values like npm and vscode_extension show up alongside the Homebrew ones. For v1.2.0 I added sections for both.
These are read-only. Workbrew has no per-device inventory or “outdated version” data for npm packages or extensions, only the vulnerability feed, so there is nothing to put behind an upgrade checkbox. The script sorts each match into its own bucket:
case "$package_type" in
npm)
VULN_INFO_NPM[$package_name]="$entry_summary"
;;
vscode_extension)
VULN_INFO_VSCODE_EXTENSION[$package_name]="$entry_summary"
;;
*)
VULN_INFO[$package_name]="$entry_summary"
;;
esac
Supporting those types also exposed a bug that had been silently returning nothing. The loop was reading formula and outdated_devices, but the fields in the current API are package and affected_devices. Nothing errored; the dashboard just showed zero vulnerabilities. If you parse a JSON API with plutil in a loop that ends on an empty value, a renamed field looks exactly like a clean result.
Small Bugs Worth Writing Down
BSD mktemp and suffixes. The checkbox JSON was built with a template ending in .json. On macOS, mktemp only randomizes a trailing run of X characters, so the suffix meant the filename was literally the same every time. The first run worked. Every run after that failed with “File exists”, and swiftDialog exited immediately with no window. The fix was to drop the suffix; swiftDialog’s --jsonfile doesn’t care about the extension.
Dots in labels. I match checkbox results back to packages by exact string rather than parsing the output with plutil, because plutil -extract treats . as a nesting separator. A label like [email protected]: 3.12.1 -> 3.12.2 would break the keypath. grep -F on the label is less elegant and handles it correctly.
Commas in dialog attributes. swiftDialog’s list item attributes are comma-delimited, so CVE lists are joined with / rather than , . A literal comma silently splits the attribute.
A stalled update call. v1.0.3 removed an explicit brew update call. Workbrew’s own background daemon already keeps the tap index in sync, and the extra call could stall for minutes while the user stared at a spinner.
Configuration
Two Jamf script parameters, $4 for the Workbrew API key and $5 for the workspace name. Locally, the script pulls both from a 1Password item through the op CLI and falls back to the Jamf parameters, so the same script runs in either place. The API key is an ordinary encrypted Jamf parameter; Self Service doesn’t expose parameter values or script source to the end user, which is the same trust model as any other root-only policy script.
One gotcha for local testing: the 1Password desktop app’s CLI integration is tied to your login session, not root. Under plain sudo, op may fail even though it works as you, and the script quietly falls through to the (empty) Jamf parameters. If that happens, either sign in with a method that works for any UID or pass the values explicitly on the command line.
What I’d Do Differently
The script is 800 lines of zsh doing JSON parsing through plutil -extract with index loops. It works and has no dependencies beyond what ships on macOS, but a lot of the code exists to work around not having jq. If I were starting over I’d decide up front whether the “no extra binaries” constraint is worth that.
The first version of the per-package flow was a single-select dropdown, because it was the only swiftDialog input pattern I had already proven in other scripts. Once I checked the installed swiftDialog’s help output and confirmed checkbox and JSON support, the checkbox list was the obvious replacement. I should have checked sooner instead of designing around a limitation I assumed.