From 011700cdc338d668e17bf24c0239a8daba3520f2 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 17:51:30 -0500 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20prompt=20for=20sudo?= =?UTF-8?q?=20once=20in=20update,=20keep-alive=20the=20time=20stamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update alias asked for the password twice: brew/npm/uv run long enough between `sudo softwareupdate` and the trailing `sudo gem` steps that the 5-minute sudo timestamp expires. Now an update() function in .functions authenticates upfront and refreshes the timestamp every 60s in the background (same pattern as ~/.macos), with an EXIT trap killing the loop even on Ctrl-C. Co-Authored-By: Claude Fable 5 --- .aliases | 3 --- .functions | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.aliases b/.aliases index 6a6deb228fc..35aa56141e2 100644 --- a/.aliases +++ b/.aliases @@ -54,9 +54,6 @@ alias sudo='sudo ' # Get week number alias week='date +%V' -# Get macOS Software Updates, and update installed Ruby gems, Homebrew, npm, uv, and their installed packages -alias update='sudo softwareupdate -i -a; brew update; brew upgrade; brew cleanup; command -v npm > /dev/null 2>&1 && npm install npm -g; command -v npm > /dev/null 2>&1 && npm update -g; command -v uv > /dev/null 2>&1 && uv tool upgrade --all; [[ $(which ruby) != /usr/bin/ruby ]] && sudo gem update --system; [[ $(which ruby) != /usr/bin/ruby ]] && sudo gem update; [[ $(which ruby) != /usr/bin/ruby ]] && sudo gem cleanup' - # Google Chrome alias chrome='/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome' alias canary='/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary' diff --git a/.functions b/.functions index c3996a1749f..5639b37c87c 100644 --- a/.functions +++ b/.functions @@ -220,3 +220,25 @@ function gh() { fi; GH_TOKEN="$(command gh auth token --user "$acct" 2>/dev/null)" command gh "$@"; } + +# Get macOS Software Updates, and update installed Ruby gems, Homebrew, npm, uv, and their installed packages +function update() { + # Ask for the administrator password upfront, then keep the sudo time + # stamp alive until the updates finish (same pattern as ~/.macos) + setopt localoptions nomonitor nonotify; + sudo -v || return; + while true; do sudo -n true; sleep 60; done 2>/dev/null & + local keepalive_pid=$!; + trap 'kill "$keepalive_pid" 2>/dev/null' EXIT; + + sudo softwareupdate -i -a; + brew update; + brew upgrade; + brew cleanup; + command -v npm > /dev/null 2>&1 && npm install npm -g; + command -v npm > /dev/null 2>&1 && npm update -g; + command -v uv > /dev/null 2>&1 && uv tool upgrade --all; + [[ $(which ruby) != /usr/bin/ruby ]] && sudo gem update --system; + [[ $(which ruby) != /usr/bin/ruby ]] && sudo gem update; + [[ $(which ruby) != /usr/bin/ruby ]] && sudo gem cleanup; +} From 6177805d496ba501bd6f113e418209605dedfcc7 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 18:25:53 -0500 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=A7=B0=20chore:=20=F0=9F=94=A5=20st?= =?UTF-8?q?op=20tracking=20machine-local=20gpg-agent.conf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It hard-coded /opt/homebrew/bin/pinentry-mac (Apple Silicon macOS only, breaks when deployed to Linux) and is vestigial anyway — commit signing here is SSH-format via 1Password with no GPG keypair. The deployed ~/.gnupg/gpg-agent.conf stays; it is just no longer managed by the repo. Co-Authored-By: Claude Fable 5 --- .gitignore | 6 +++--- .gnupg/gpg-agent.conf | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 .gnupg/gpg-agent.conf diff --git a/.gitignore b/.gitignore index 4646027808d..66fba72c89c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,9 @@ Thumbs.db # Scratch pads scratch.* -# GPG — only track agent config, never keys or trust database -.gnupg/* -!.gnupg/gpg-agent.conf +# GPG — never track anything under .gnupg; agent config is machine-local +# (pinentry program paths differ per OS and Homebrew prefix) +.gnupg/ # Generated zsh completion cache .zcompdump diff --git a/.gnupg/gpg-agent.conf b/.gnupg/gpg-agent.conf deleted file mode 100644 index e9a2e550e6f..00000000000 --- a/.gnupg/gpg-agent.conf +++ /dev/null @@ -1,3 +0,0 @@ -pinentry-program /opt/homebrew/bin/pinentry-mac -default-cache-ttl 3600 -max-cache-ttl 86400 From db66a7be65a1e52f79f1ab446180e474528cef9c Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 18:26:38 -0500 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=F0=9F=94=A7=20move?= =?UTF-8?q?=20commit.gpgsign=20opt-in=20to=20~/.gitconfig.local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracked .gitconfig enabled signing globally, so every fresh machine without the 1Password signer failed all commits until manually disabled. Signing now lives entirely in machine-local config alongside the rest of the signing setup (gpg.format, op-ssh-sign path, signingkey); fresh boxes default to unsigned commits. Co-Authored-By: Claude Fable 5 --- .gitconfig | 5 ----- CLAUDE.md | 25 ++++++++++++++----------- bootstrap.sh | 3 ++- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.gitconfig b/.gitconfig index 6099e87c464..29e3380df09 100644 --- a/.gitconfig +++ b/.gitconfig @@ -143,11 +143,6 @@ changed = green untracked = cyan -[commit] - - # https://help.github.com/articles/signing-commits-using-gpg/ - gpgsign = true - [diff] # Detect copies as well as renames diff --git a/CLAUDE.md b/CLAUDE.md index 4ea5cc9f59b..c62394ab9c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,21 +60,24 @@ CI runs tests on both `ubuntu-latest` and `macos-latest` via `.github/workflows/ ### Commit signing -Commits are signed with **SSH-format signatures via 1Password**, not GPG. The -repo sets `commit.gpgsign = true` (format-agnostic); the actual mechanism lives -in untracked machine-local files: - -- `~/.gitconfig.local` / `~/.gitconfig-ica` set `gpg.format = ssh`, - `gpg.ssh.program = .../op-ssh-sign`, and `user.signingkey = ~/.ssh/*.pub`. +Commits are signed with **SSH-format signatures via 1Password**, not GPG. +Signing is entirely machine-local: the tracked `.gitconfig` does NOT enable +it, so a fresh machine defaults to unsigned commits instead of failing on a +missing signer. Each machine opts in via untracked files: + +- `~/.gitconfig.local` / `~/.gitconfig-ica` set `commit.gpgsign = true`, + `gpg.format = ssh`, `gpg.ssh.program = .../op-ssh-sign`, and + `user.signingkey = ~/.ssh/*.pub`. - `~/.ssh/config` points `IdentityAgent` at the 1Password agent socket; the private keys live in 1Password and never touch disk. The `.pub` files are just selectors. -On a machine without 1Password (e.g. a remote Linux box), `op-ssh-sign` doesn't -exist and the agent socket is absent. Either forward your local 1Password SSH -agent over the connection, or disable signing there with -`git config commit.gpgsign false`. There is **no GPG keypair** in this setup -despite the `gpgsign` name. +On a machine without 1Password (e.g. a remote Linux box), nothing needs +disabling — signing is simply never enabled there. To sign on a remote you +keep around, forward your local 1Password SSH agent over the connection and +set `user.signingkey` to the literal public key (not `op-ssh-sign`, which +only exists locally). There is **no GPG keypair** in this setup despite the +`gpgsign` name. ### Machine-local customization diff --git a/bootstrap.sh b/bootstrap.sh index cc9682918b3..a665c1d5fa2 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -27,7 +27,8 @@ function doIt() { echo "Next steps on a new machine:"; echo " brew bundle — install Brewfile packages"; echo " 1Password → set up SSH agent + op-ssh-sign, then add signing config"; - echo " to ~/.gitconfig.local (gpg.format=ssh, signingkey=*.pub)"; + echo " to ~/.gitconfig.local (commit.gpgsign=true,"; + echo " gpg.format=ssh, signingkey=*.pub)"; echo " bash init/mackup.sh — restore app settings from ~/.config/Mackup/"; } From 33d43911e03d006493294ef049e6527dd4cfbfab Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 18:27:03 -0500 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=F0=9F=94=A8=20hard?= =?UTF-8?q?en=20bootstrap=20for=20offline,=20bash,=20and=20non-mac=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The rsync exclude still said ".osx" (renamed upstream to .macos years ago), so the mac-only defaults script was landing in every $HOME. - git pull now uses --ff-only and tolerates being offline or on a diverged clone instead of erroring mid-bootstrap. - ~/.zshrc is zsh-only syntax but was sourced unconditionally from this bash script; now only sourced when running under zsh. Co-Authored-By: Claude Fable 5 --- bootstrap.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bootstrap.sh b/bootstrap.sh index a665c1d5fa2..e4346f91ea9 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -2,13 +2,13 @@ cd "$(dirname "${BASH_SOURCE}")"; -git pull origin master; +git pull --ff-only origin master || echo "Could not pull latest changes; continuing with local copy."; function doIt() { rsync --exclude ".git/" \ --exclude ".claude/settings.local.json" \ --exclude ".DS_Store" \ - --exclude ".osx" \ + --exclude ".macos" \ --exclude "tests/" \ --exclude "bootstrap.sh" \ --exclude "README.md" \ @@ -22,7 +22,12 @@ function doIt() { "${WSL_DISTRO_NAME:+, WSL: $WSL_DISTRO_NAME}" \ "${TMUX:+, tmux}" > ~/.claude/CLAUDE.local.md; fi; - source ~/.zshrc; + # ~/.zshrc is zsh-only syntax; only source it when actually running in zsh + if [ -n "$ZSH_VERSION" ]; then + source ~/.zshrc; + else + echo "Restart your shell (or run: exec zsh) to pick up the new config."; + fi; echo ""; echo "Next steps on a new machine:"; echo " brew bundle — install Brewfile packages"; From d44dd97b7e3418f340c1a2a9702ba8687ae51c76 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 18:27:11 -0500 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=A7=B0=20chore:=20=E2=9A=B0?= =?UTF-8?q?=EF=B8=8F=20remove=20bin/itunes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 842 lines of AppleScript targeting the iTunes app, which no longer exists on modern macOS (replaced by Music) and never existed on Linux. Co-Authored-By: Claude Fable 5 --- bin/itunes | 841 ----------------------------------------------------- 1 file changed, 841 deletions(-) delete mode 100755 bin/itunes diff --git a/bin/itunes b/bin/itunes deleted file mode 100755 index 8cf3a8e45de..00000000000 --- a/bin/itunes +++ /dev/null @@ -1,841 +0,0 @@ -#!/bin/bash -# -# A simple command-line wrapper for Apple's iTunes. -# -# Install or symlink into your path and "itunes -h" or "itunes help" for help. -# -# Copyright 2012 Sam Graham. http://www.illusori.co.uk/ -# This work is licensed under a -# Creative Commons Attribution-Share Alike 2.0 UK: England & Wales License -# http://creativecommons.org/licenses/by-sa/2.0/uk/ -# In human-readble terms: you're free to copy, distribute and modify -# providing you maintain attribution and licence. -# -# Use at your own risk, no fitness for purpose implied, etc, etc. - -version='1.2.3' -progname=`basename $0` -config_dir="$HOME/.bash-itunes" -plugins_dir="$config_dir/plugins" - -# Version and help. - -function _cmd_version() { - echo "$progname version $version" -} - -function _help_help() { - _cmd_help -} - -function _cmd_help() { - if [ -z "$*" ]; then - _cmd_version - echo "Usage: $progname [-v] [-h] [-d] [] - -Switches: - -v Show version information and exit. - -h Show this help and exit. - -d Enable debug messages, additional -d options increase verbosity. - -Navigation commands: - play Resume playing or choose track. - pause Pause playing. - stop Stop playing. - next Skip to next track. - prev Skip back to previous track. - info View info about current track. - search Search for tracks. - shuffle View or set shuffle status. - repeat View or set repeat status. - playlist View or choose playlist. - playlists List all playlists. - -Volume commands: - vol[ume] View or adjust iTunes volume 'up', 'down' or percentage. - mute Mutes iTunes. - unmute Unmutes iTunes, restoring previous volume level. - sysvol[ume] View or adjust system volume 'up', 'down' or percentage. - sysmute Mute system sound. - sysunmute Unmute system sound. - -Application commands: - open Start iTunes running. - quit Quit iTunes. - show Hide the iTunes window. - hide Show the iTunes window. - -Additional commands: - version Show version information and exit. - help Show this help and exit. - plugins Show a list of plugins installed." - else - # Dispatch to _help_ if they've supplied a command name. - orig_cmd="$1" - shift - _normalize_cmd_name "cmd" "$orig_cmd" - if [ -z "$cmd" ]; then - _cmd_unknown "$orig_cmd" - else - # TODO: check the functions exists. Say no help available if it doesn't. - local something_shown=0 - declare -f "_usage_${cmd}" >/dev/null - if [ "$?" = "0" ]; then - _usage_${cmd} "$@" - something_shown=1 - fi - declare -f "_help_${cmd}" >/dev/null - if [ "$?" = "0" ]; then - if [ "$something_shown" = "1" ]; then - echo - fi - _help_${cmd} "$@" - something_shown=1 - fi - if [ "$something_shown" = "0" ]; then - echo "No help found for '$progname $cmd', try the general help at '$progname help'." - fi - fi - fi -} - -function _cmd_unknown() { - echo "Unknown command '$*', try '$progname help' for a list of valid commands." -} - -# Helper functions - -function _err() { - echo "$*" 1>&2 -} - -function _err_func() { - ${1} 1>&2 -} - -function _log() { - if [ "$debug" -ge "$1" ]; then - shift; - _err "$*" - fi -} - -function _pluralize() { - if [ "$1" = "1" ]; then - echo "$1 $2" - else - if [ -n "$3" ]; then - echo "$1 $3" - else - echo "$1 ${2}s" - fi - fi -} - -function _quote_for_applescript_string() { - local value="$*" - - value="${value//\\/\\\\}" - value="${value//\"/\\\"}" - - _log 4 "Escaping '$*' to '$value' for use in applescript string" - - echo "$value" -} - -function _osascript() { - _log 2 "Run: osascript -e \"$*\"" - if [ "$debug" -gt 0 ]; then - osascript -e "$*" - else - osascript -e "$*" 2>&- - fi -} - -function _tell_itunes() { - _osascript "tell application \"iTunes\" to $*" -} - -function _ask_itunes() { - _osascript "tell application \"iTunes\" - $* - set AppleScript's text item delimiters to \"\\n\" - ret as text - end tell" -} - -# Fetch from iTunes from the data-structures in $1 into the array -# variables constructed from prefix $2 and column names $3+ the -# given columns in the data-structure. -# If that's clear. -# No? -# Anyone? -function _fetch_from_itunes() { - local source="$1" - local use_loop=0 - if [ "$source" = "LOOP" ]; then - use_loop=1 - source="$2" - shift - fi - local prefix="$2" - shift 2 - # Yet more hacks, this allows multi-word AppleScript properties - # such as "database id" to end up in "database_id". - # You can also do this by messing with $IFS and ${var//search/replace} - # but, although I got it working, I had a brief outbreak of sanity and - # chose this instead... - local columns='' - local script_columns='' - for column in "$@"; do - columns="${columns} ${column// /_}" - script_columns="${script_columns}, ${column}" - done - columns="${columns:1}" - script_columns="${script_columns:2}" - - _log 4 "source '$structure', prefix '$prefix', columns '$columns', script_columns '$script_columns'" - - # Best if we don't loop within AppleScript, it can be painfully slow - # for large arrays, but on the other hand, for columns that are - # actually expressions rather than properties, we appear to have to loop. - if [ "$use_loop" = 0 ]; then - local result=$(_ask_itunes "set ret to ($script_columns) of $source") - _log 4 "result '$result'" - _read_rows_from_value_lines_grouped_by_column "$prefix" "$result" $columns - else - local result=$(_ask_itunes "set ret to {} - repeat with v in $source as list - copy ($script_columns) of v to the end of ret - end repeat") - _log 4 "result '$result'" - _read_rows_from_value_lines "$prefix" "$result" $columns - fi - -} - -# Low-level row/rowset fetching/clearing. This stuff is nasty. - -# This function is lifted from my bash-snippets repo on GitHub, -# gawp in horror at its unspeakable glory. -function _read_rows_from_value_lines_grouped_by_column() { - local prefix="$1" - local content="$2" - shift 2 - local columns - read -r -a columns <<< "$*" - - local row_count=$(($(wc -l <<< "$content") / ${#columns[*]})) - - for column in "${columns[@]}"; do - unset "${prefix}_${column}" - done - - local row_idx=0 - local column_idx=0 - local value - while read value; do - column=${columns[column_idx]} - # Ho hum, I gave up and used eval, couldn't get this working with $(()) - printf -v value "%q" "$value" - eval "${prefix}_${column}[${row_idx}]=$value" - - row_idx=$((row_idx + 1)) - if [ "$row_idx" -ge "$row_count" ]; then - row_idx=0 - column_idx=$((column_idx + 1)) - fi - #_log 5 "Column is $column, column_idx now $column_idx, row_idx now $row_idx, value $value" - done <<< "$content" -} -function _read_rows_from_value_lines() { - local prefix="$1" - local content="$2" - shift 2 - local columns - read -r -a columns <<< "$*" - - for column in "${columns[@]}"; do - #echo "Scrubbing ${prefix}_${column}" - unset "${prefix}_${column}" - done - - local row_idx=0 - local column_idx=0 - local value - while read value; do - column=${columns[column_idx]} - # Ho hum, I gave up and used eval, couldn't get this working with $(()) - printf -v value "%q" "$value" - eval "${prefix}_${column}[${row_idx}]=$value" - - column_idx=$((column_idx + 1)) - if [ "$column_idx" -ge "${#columns[*]}" ]; then - column_idx=0 - row_idx=$((row_idx + 1)) - fi - #_log 5 "Column is $column, column_idx now $column_idx, row_idx now $row_idx" - done <<< "$content" -} - -function _scrub_rows() { - local prefix="$1" - shift - local columns="$@" - - for column in $columns; do - local full_column="${prefix}_${column}" - _log 6 "Scrubbing \$$full_column" - unset $full_column - done -} - -# Schema stuff. - -_track_columns="name artist album year rating time id" -_playlist_columns="name time id" - -# Fetchers/scrubbers for the objects in the schema. - -function _fetch_tracks() { - _fetch_from_itunes "$@" $_track_columns -} -function _scrub_tracks() { - local prefix="$1" - _scrub_rows "$prefix" $_track_columns -} - -function _fetch_playlists() { - local source="$1" - local prefix="$2" - _fetch_from_itunes "$@" $_playlist_columns - _fetch_from_itunes LOOP "$source" "$prefix" "count tracks" -} -function _scrub_playlists() { - local prefix="$1" - _scrub_rows "$prefix" $_playlist_columns - _scrub_rows "$prefix" count_tracks -} - -# Fetchers/scrubbers for specific global objects. - -function _fetch_current_track() { - if [ -z "$current_track_name" ]; then - _fetch_tracks "current track" current_track - fi - if [ -z "$current_track_pos" ]; then - current_track_pos=$(_tell_itunes "player position as integer") - local min=$((current_track_pos / 60)) - local sec=$(printf "%02d" $((current_track_pos % 60))) - current_track_pos="${min}:${sec}" - _log 3 "Fetched trackpos '$current_track_pos'" - fi -} -function _scrub_current_track() { - _scrub_tracks "current_track" - unset "current_track_pos" -} - -function _fetch_current_playlist() { - if [ -z "$current_playlist_name" ]; then - _fetch_playlists "current playlist" current_playlist - fi -} -function _scrub_current_playlist() { - _scrub_playlists "current_playlist" -} - - -# Views. - -function _show_track() { - local row_prefix="$1" - local row_idx="${2:-0}" - local brief="${3:-0}" - local preamble="$4" - - # Ho ho. - eval "local name=\${${row_prefix}_name[row_idx]}" - eval "local artist=\${${row_prefix}_artist[row_idx]}" - eval "local album=\${${row_prefix}_album[row_idx]}" - eval "local year=\${${row_prefix}_year[row_idx]}" - eval "local rating=\${${row_prefix}_rating[row_idx]}" - eval "local time=\${${row_prefix}_time[row_idx]}" - eval "local id=\${${row_prefix}_id[row_idx]}" - - if [ -n "$preamble" ]; then - preamble="$preamble " - fi - - echo -n "$preamble\"$name\"" - if [ -n "$artist" ]; then - echo -n " by $artist" - fi - if [ -n "$album" ]; then - echo -n ", from \"$album\"" - if [ -n "$year" -a "$year" != 0 ]; then - echo -n " ($year)" - fi - fi - if [ -n "$current_track_id" -a -n "$current_track_pos" -a "$current_track_id" = "$id" ]; then - echo " ($current_track_pos of $time)" - else - echo " ($time)" - fi - if [ "$brief" = 0 -a -n "$rating" -a "$rating" != 0 ]; then - echo "You've rated this track $rating." - fi - -} - -function _show_current_track() { - local preamble="$1" - local brief="${2:-0}" - - _fetch_current_track - _show_track current_track 0 $brief "$preamble" -} - -function _show_tracks() { - local prefix="$1" - local brief="${2:-0}" - - local track_idx=0 - eval "local track_name=\${${prefix}_name[track_idx]}" - eval "local track_id=\${${prefix}_id[track_idx]}" - while [ -n "$track_name" ]; do - if [ "$current_track_id" = "$track_id" ]; then - echo -n "* " - else - echo -n " " - fi - _show_track $prefix $track_idx $brief - track_idx=$((track_idx + 1)) - eval "track_name=\${${prefix}_name[track_idx]}" - eval "track_id=\${${prefix}_id[track_idx]}" - done -} - -function _show_playlist() { - local row_prefix="$1" - local row_idx="${2:-0}" - local brief="${3:-0}" - local preamble="$4" - - # Ho ho. - eval "local name=\${${row_prefix}_name[row_idx]}" - eval "local time=\${${row_prefix}_time[row_idx]}" - eval "local id=\${${row_prefix}_id[row_idx]}" - eval "local count_tracks=\${${row_prefix}_count_tracks[row_idx]}" - - if [ -n "$preamble" ]; then - preamble="$preamble " - fi - - echo -n "$preamble\"${name}\" (${time}) (" - echo -n $(_pluralize "${count_tracks}" 'track') - echo -n ")" - if [ "$brief" = 0 ]; then - echo ":" - _show_tracks "${row_prefix}_tracks" 1 - else - echo - fi -} - -function _show_current_playlist() { - local preamble="$1" - local brief="${2:-0}" - - _fetch_current_track - _fetch_current_playlist - _fetch_tracks "every track of current playlist" current_playlist_tracks - - _show_playlist current_playlist 0 $brief "$preamble" -} - -# Navigation commands: play, pause, stop, next, prev, playlist - -function _cmd_play() { - if [ -z "$*" ]; then - _tell_itunes "play" - _scrub_current_track - _show_current_track "Resuming" 1 - else - _tell_itunes "play item 1 of ((tracks of current playlist whose name is \"$*\") & (tracks of current playlist whose name contains \"$*\") & (tracks whose name is \"$*\") & (tracks whose name contains \"$*\"))" - _scrub_current_track - _show_current_track "Now playing" 1 - fi -} - -function _cmd_pause() { - echo "Pausing iTunes." - _tell_itunes "pause" -} - -function _cmd_stop() { - echo "Stopping iTunes." - _tell_itunes "stop" -} - -function _cmd_next() { - _tell_itunes "next track" - _scrub_current_track - _show_current_track "Skipping to next track," 1 -} - -function _cmd_prev() { - _tell_itunes "previous track" - _scrub_current_track - _show_current_track "Skipping to previous track," 1 -} - -function _cmd_info() { - if [ -z "$*" ]; then - _cmd_info_track - else - local cmd="$1" - shift - case "$cmd" in - track) - _cmd_info_track "$@" - ;; - playlist) - _cmd_info_playlist "$@" - ;; - esac - fi -} - -function _cmd_info_track() { - if [ -z "$*" ]; then - _show_current_track "Currently playing" - else - _fetch_tracks "track \"$*\"" track - _show_track track 0 0 - fi -} - -function _cmd_info_playlist() { - if [ -z "$*" ]; then - _show_current_playlist "Currently listening to playlist" 0 - else - _fetch_playlists "playlist \"$*\"" playlist - _fetch_tracks "every track of playlist \"$*\"" playlist_tracks - _show_playlist playlist 0 0 - fi -} - -function _usage_search() { - echo "Usage: $progname search [] " -} - -function _cmd_search() { - if [ -z "$*" ]; then - _err_func _usage_search - else - local target - local desc - case "$1" in - track | tracks | song | songs) - target='songs'; desc='name'; shift ;; - album | albums) target='albums'; desc='album title'; shift ;; - artist | artists) target='artists'; desc='artist'; shift ;; - all) target='all'; desc='anything'; shift ;; - *) target='all' desc='anything' ;; - esac - local search_term="$*" - local escaped_search_term=$(_quote_for_applescript_string "$search_term") - _fetch_tracks LOOP "(search playlist \"Library\" for \"$escaped_search_term\" only $target)" search_tracks - echo "Searching for tracks with $desc containing \"$search_term\":" - _show_tracks "search_tracks" 1 - fi -} - -function _cmd_shuffle() { - if [ -z "$1" ]; then - curshuffle=$(_tell_itunes "shuffle of current playlist") - _log 3 "Fetched shuffle is '$curshuffle'" - if [ "$curshuffle" = "true" ]; then - echo "Current shuffle setting is on." - else - echo "Current shuffle setting is off." - fi - else - case "$1" in - "on") - echo "Switching shuffle on." - _tell_itunes "set shuffle of current playlist to true" - ;; - "off") - echo "Switching shuffle off." - _tell_itunes "set shuffle of current playlist to false" - ;; - *) - _err "Shuffle must be one of 'on' or 'off'." - ;; - esac - fi -} - -function _cmd_repeat() { - if [ -z "$1" ]; then - currepeat=$(_tell_itunes "song repeat of current playlist") - _log 3 "Fetched repeat is '$currepeat'" - echo "Current repeat setting is $currepeat." - else - case "$1" in - "off" | "one" | "all") - echo "Switching repeat to $1." - _tell_itunes "set song repeat of current playlist to $1" - ;; - *) - _err "Repeat must be one of 'one', 'all' or 'off'." - ;; - esac - fi -} - -function _cmd_playlist() { - if [ -z "$1" ]; then - _cmd_info_playlist - else - _tell_itunes "play item 1 of ((playlists whose name is \"$*\") & (playlists whose name contains \"$*\"))" - _scrub_current_track - _scrub_current_playlist - _fetch_current_playlist - _show_current_track "Now listening to playlist \"${current_playlist_name}\" (${current_playlist_time}), playing" 1 - fi -} - -function _cmd_playlists() { - _fetch_playlists "playlists" playlist - echo "Playlists" - local playlist_idx=0 - while [ -n "${playlist_name[playlist_idx]}" ]; do - _show_playlist playlist $playlist_idx 1 " " - playlist_idx=$((playlist_idx + 1)) - done -} - -# Volume commands: volume, mute, unmute - -function _cmd_volume() { - curvol=$(_tell_itunes "sound volume as integer") - _log 3 "Fetched volume is '$curvol'" - if [ -z "$1" ]; then - echo "Current iTunes volume is $curvol%" - else - newvol=-1 - case "$1" in - "up") newvol=$(($curvol + 10)) ;; - "down") newvol=$(($curvol - 10)) ;; - "mute") _cmd_mute ;; - "unmute") _cmd_unmute ;; - *) - if [ "$1" -ge 0 -a "$1" -le 100 2>&- ] ; then - newvol=$1 - else - _err "Volume must be one of 'up', 'down', 'mute', 'unmute' or an integer between 0 and 100." - fi - ;; - esac - if [ $newvol != -1 ]; then - echo "Changing iTunes volume level from $curvol to $newvol%" - _tell_itunes "set sound volume to $newvol" - fi - fi -} - -function _cmd_mute() { - echo "Muting iTunes." - _tell_itunes "set mute to true" -} - -function _cmd_unmute() { - echo "Unmuting iTunes." - _tell_itunes "set mute to false" -} - -# Not strictly iTunes, but likely to be useful anyway. -function _cmd_sysvolume() { - curvol=$(_osascript "output volume of (get volume settings)") - _log 3 "Fetched volume is '$curvol'" - if [ -z "$1" ]; then - echo "Current system volume is $curvol%" - else - newvol=-1 - case "$1" in - "up") newvol=$(($curvol + 10)) ;; - "down") newvol=$(($curvol - 10)) ;; - "mute") _cmd_sysmute ;; - "unmute") _cmd_sysunmute ;; - *) - if [ "$1" -ge 0 -a "$1" -le 100 2>&- ] ; then - newvol=$1 - else - _err "Volume must be one of 'up', 'down', 'mute', 'unmute' or an integer between 0 and 100." - fi - ;; - esac - if [ $newvol != -1 ]; then - echo "Changing system volume level from $curvol to $newvol%" - _osascript "set volume output volume $newvol" - fi - fi -} - -function _cmd_sysmute() { - echo "Muting system sound." - _osascript "set volume output muted true" -} - -function _cmd_sysunmute() { - echo "Unmuting system sound." - _osascript "set volume output muted false" -} - -# Application commands: open, quit - -function _cmd_open() { - echo "Opening iTunes." - open -a iTunes -} - -function _cmd_quit() { - echo "Quitting iTunes." - _tell_itunes "quit" -} - -function _cmd_show() { - echo "Showing iTunes window." - _osascript "tell application \"System Events\" to set visible of process \"iTunes\" to true" -} - -function _cmd_hide() { - echo "Hiding iTunes window." - _osascript "tell application \"System Events\" to set visible of process \"iTunes\" to false" -} - -function _cmd_plugins() { - echo "Installed plugins:" - local GLOBIGNORE="*~" - local glob="$plugins_dir/*" - local plugins=($glob) - if [ -z "${plugins[*]}" -o "${plugins[*]}" = "$glob" ]; then - echo " No plugins installed." - else - for plugin in "${plugins[@]}"; do - local plugin_cmd=`basename $plugin` - echo -n " $plugin_cmd - " - if [ -f "$plugin" ]; then - . "$plugin" - if [ -z "$PLUGIN_NAME" ]; then - PLUGIN_NAME="Missing PLUGIN_NAME" - fi - if [ -z "$PLUGIN_VERSION" ]; then - PLUGIN_VERSION="Missing PLUGIN_VERSION" - fi - if [ -z "$PLUGIN_BRIEF" ]; then - PLUGIN_BRIEF="Missing PLUGIN_BRIEF" - fi - echo "$PLUGIN_NAME ($PLUGIN_VERSION)" - echo " $PLUGIN_BRIEF" - unset PLUGIN_NAME - unset PLUGIN_VERSION - unset PLUGIN_BRIEF - else - echo "Not a plugin file" - fi - done - fi -} - -# Argument processing and dispatch - - -function _normalize_cmd_name() { - # Clunky way to return value because $() runs in subshell and we want to - # auto-source plugins into the main bash-itunes shell. But we also don't - # want to screw around with fd redirect voodoo at the calling point each - # time to capture process output without a subshell. - local _varname="$1" - local _cmd="$2" - case "$_cmd" in - # Navigation commands. - play|resume|unpause) - _cmd="play" ;; - pause) _cmd="pause" ;; - stop) _cmd="stop" ;; - next) _cmd="next" ;; - prev) _cmd="prev" ;; - info) _cmd="info" ;; - search) _cmd="search" ;; - shuffle) _cmd="shuffle" ;; - repeat) _cmd="repeat" ;; - playlist) _cmd="playlist" ;; - playlists) _cmd="playlists" ;; - # Volume commands. - vol|volume) _cmd="volume" ;; - mute) _cmd="mute" ;; - unmute) _cmd="unmute" ;; - sysvol|sysvolume) - _cmd="sysvolume" ;; - sysmute) _cmd="sysmute" ;; - sysunmute) _cmd="sysunmute" ;; - # Application commands. - open) _cmd="open" ;; - quit) _cmd="quit" ;; - show|reveal) _cmd="show" ;; - hide) _cmd="hide" ;; - # Extra commands. - version) _cmd="version" ;; - ""|help) _cmd="help" ;; - plugins) _cmd="plugins" ;; - *) - if [ -f "$plugins_dir/$_cmd" ]; then - . "$plugins_dir/$_cmd" - else - _cmd="" - fi - ;; - esac - eval "${_varname}=\"${_cmd}\"" -} - -# Argument processing and dispatch - -function _dispatch() { - orig_cmd="$1" - shift - _normalize_cmd_name "cmd" "$orig_cmd" - if [ -z "$cmd" ]; then - _cmd_unknown "$orig_cmd" - else - _cmd_${cmd} "$@" - fi -} - -debug=0 -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - # Being run directly, do our command processing. - while getopts "hvd" "OPTNAME"; do - case $OPTNAME in - h) _cmd_help; exit 0 ;; - v) _cmd_version; exit 0 ;; - d) debug=$(($debug + 1)) ;; - *) exit -1 ;; - esac - done - shift $(($OPTIND - 1)) -fi - -# Run rc file after options processing so that it can inspect arguments. -if [ -f "$config_dir/config" ]; then - . "$config_dir/config" -fi - -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - # Resume "being run directly" behaviour. - _dispatch "$@" -fi From ce28f89087109cbfe1226daa0753f0237ee90925 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 18:27:43 -0500 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=F0=9F=94=90=20actu?= =?UTF-8?q?ally=20render=20~/.extra=20via=20op=20inject=20in=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .extra.tmpl and CLAUDE.md both claimed bootstrap.sh renders the secrets template, but no such call existed — a fresh machine silently ended up with no ~/.extra. Bootstrap now renders it when the op CLI is present and ~/.extra doesn't exist yet, and the "Next steps" output documents the manual command for every other case. Co-Authored-By: Claude Fable 5 --- .extra.tmpl | 2 +- bootstrap.sh | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.extra.tmpl b/.extra.tmpl index 3e9326343e5..4a828deb1db 100644 --- a/.extra.tmpl +++ b/.extra.tmpl @@ -13,7 +13,7 @@ # IN DOUBLE CURLY BRACES. The commented examples show the path format; # to activate one, uncomment it and wrap the op://... path in {{ }}. # (In the 1Password app: right-click a field -> "Copy Secret Reference".) -# 3. Re-run `source bootstrap.sh`, or just: +# 3. Re-render (bootstrap.sh only renders when ~/.extra is missing): # op inject -i .extra.tmpl -o ~/.extra -f # # With no braced references present, this renders to an (inert) ~/.extra. diff --git a/bootstrap.sh b/bootstrap.sh index e4346f91ea9..473cc0e6b43 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -22,6 +22,12 @@ function doIt() { "${WSL_DISTRO_NAME:+, WSL: $WSL_DISTRO_NAME}" \ "${TMUX:+, tmux}" > ~/.claude/CLAUDE.local.md; fi; + # Render machine-local secrets from the tracked template (op:// references + # only — values come from 1Password). Never overwrites an existing ~/.extra. + if command -v op > /dev/null 2>&1 && [ ! -f ~/.extra ]; then + op inject -i .extra.tmpl -o ~/.extra \ + || echo "op inject failed (not signed in?) — render ~/.extra manually later."; + fi; # ~/.zshrc is zsh-only syntax; only source it when actually running in zsh if [ -n "$ZSH_VERSION" ]; then source ~/.zshrc; @@ -35,6 +41,9 @@ function doIt() { echo " to ~/.gitconfig.local (commit.gpgsign=true,"; echo " gpg.format=ssh, signingkey=*.pub)"; echo " bash init/mackup.sh — restore app settings from ~/.config/Mackup/"; + echo " op inject -i .extra.tmpl -o ~/.extra"; + echo " — render secrets (auto-runs above when op is"; + echo " installed and ~/.extra doesn't exist yet)"; } if [ "$1" = "--force" -o "$1" = "-f" ]; then From ca9d830ce495b51b9362a6d0c5335a78e8672973 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 18:28:13 -0500 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=F0=9F=A6=96=20guar?= =?UTF-8?q?d=20mac-only=20update=20steps,=20add=20Linux=20clipboard/open?= =?UTF-8?q?=20shims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update() ran softwareupdate and brew unconditionally, failing on Linux; both are now guarded. pbcopy/pbpaste get xclip/xsel fallbacks and open gets xdg-open, following the existing command -v fallback style, so the clip alias and open-dependent functions work off-mac. Co-Authored-By: Claude Fable 5 --- .aliases | 16 ++++++++++++++++ .functions | 6 ++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.aliases b/.aliases index 35aa56141e2..ac6863b1b14 100644 --- a/.aliases +++ b/.aliases @@ -81,6 +81,22 @@ command -v md5sum > /dev/null || alias md5sum="md5" # macOS has no `sha1sum`, so use `shasum` as a fallback command -v sha1sum > /dev/null || alias sha1sum="shasum" +# Linux has no `pbcopy`/`pbpaste`, so use `xclip`/`xsel` as fallbacks +if ! command -v pbcopy > /dev/null; then + if command -v xclip > /dev/null; then + alias pbcopy="xclip -selection clipboard" + alias pbpaste="xclip -selection clipboard -o" + elif command -v xsel > /dev/null; then + alias pbcopy="xsel --clipboard --input" + alias pbpaste="xsel --clipboard --output" + fi +fi + +# Linux has no `open`, so use `xdg-open` as a fallback +if ! command -v open > /dev/null && command -v xdg-open > /dev/null; then + alias open="xdg-open" +fi + # JavaScriptCore REPL jscbin="/System/Library/Frameworks/JavaScriptCore.framework/Versions/A/Resources/jsc"; [ -e "${jscbin}" ] && alias jsc="${jscbin}"; diff --git a/.functions b/.functions index 5639b37c87c..51855f7e388 100644 --- a/.functions +++ b/.functions @@ -231,10 +231,8 @@ function update() { local keepalive_pid=$!; trap 'kill "$keepalive_pid" 2>/dev/null' EXIT; - sudo softwareupdate -i -a; - brew update; - brew upgrade; - brew cleanup; + [[ $(uname) == Darwin ]] && sudo softwareupdate -i -a; + command -v brew > /dev/null 2>&1 && { brew update; brew upgrade; brew cleanup; }; command -v npm > /dev/null 2>&1 && npm install npm -g; command -v npm > /dev/null 2>&1 && npm update -g; command -v uv > /dev/null 2>&1 && uv tool upgrade --all; From 185f7d803b64d16c3e63800eeb5bbf750987bc58 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 18:29:55 -0500 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=85=20test:=20source=20shell=20dotf?= =?UTF-8?q?iles=20under=20zsh=20on=20both=20CI=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI ran on ubuntu but never parsed or sourced the zsh layer, so Linux breakage in .aliases/.functions was invisible. New shell.bats parses every shell file and smoke-sources the zsh layer, failing on parse errors, missing commands, or missing files at source time. bootstrap tests now stub op (so runs never touch 1Password) and assert .macos stays out of $HOME. Co-Authored-By: Claude Fable 5 --- tests/bootstrap.bats | 7 +++++-- tests/shell.bats | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/shell.bats diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats index 029154fcb13..2423e268ca8 100755 --- a/tests/bootstrap.bats +++ b/tests/bootstrap.bats @@ -10,7 +10,8 @@ teardown() { @test "bootstrap script syncs dotfiles" { git() { :; } - export -f git + op() { :; } + export -f git op cd "$BATS_TEST_DIRNAME/.." HOME="$TMPHOME" run bash bootstrap.sh --force [ "$status" -eq 0 ] @@ -19,11 +20,13 @@ teardown() { [ -f "$TMPHOME/.claude/CLAUDE.md" ] [ ! -e "$TMPHOME/.claude/settings.local.json" ] [ -f "$TMPHOME/.claude/CLAUDE.local.md" ] + [ ! -e "$TMPHOME/.macos" ] } @test "bootstrap does not overwrite existing CLAUDE.local.md" { git() { :; } - export -f git + op() { :; } + export -f git op cd "$BATS_TEST_DIRNAME/.." mkdir -p "$TMPHOME/.claude" echo "my custom notes" > "$TMPHOME/.claude/CLAUDE.local.md" diff --git a/tests/shell.bats b/tests/shell.bats new file mode 100644 index 00000000000..2ac7101b7fd --- /dev/null +++ b/tests/shell.bats @@ -0,0 +1,33 @@ +#!/usr/bin/env bats + +# The shell dotfiles must parse and source cleanly under zsh on every CI +# platform (ubuntu + macos). Catches zsh syntax errors and unguarded +# macOS-only commands that execute at source time. + +@test "zsh parses the zsh dotfiles" { + cd "$BATS_TEST_DIRNAME/.." + for f in .zshrc .zsh_prompt .exports .aliases .functions; do + zsh -n "$f" + done +} + +@test "bash parses the bash dotfiles and bootstrap" { + cd "$BATS_TEST_DIRNAME/.." + for f in .bash_profile .bashrc bootstrap.sh; do + bash -n "$f" + done +} + +@test "exports, aliases, functions, and prompt source cleanly in zsh" { + cd "$BATS_TEST_DIRNAME/.." + run zsh -c ' + source ./.exports; true + source ./.aliases; true + source ./.functions; true + source ./.zsh_prompt; true + ' + [ "$status" -eq 0 ] + [[ "$output" != *"command not found"* ]] + [[ "$output" != *"parse error"* ]] + [[ "$output" != *"no such file"* ]] +} From e7c3798af0ca9a9266520d24e6c72950166435c6 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 19:31:18 -0500 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=91=B7=20ci:=20test=20on=20Rocky=20?= =?UTF-8?q?Linux=209=20and=20WSL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rocky runs in a rockylinux:9 container (no hosted Rocky runners exist) with bats-core installed from source. WSL runs real Ubuntu-24.04 under Vampire/setup-wsl on a windows-2025 runner; core.autocrlf is forced off before checkout because .gitattributes has line-ending normalization deliberately disabled and Windows runners default to autocrlf=true. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68206fe85cb..73d047a174e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,3 +23,38 @@ jobs: run: brew install bats-core - name: Run tests run: bats tests + + test-rocky: + # No hosted Rocky runners exist; run in a container on the ubuntu host. + runs-on: ubuntu-latest + container: rockylinux:9 + steps: + - name: Install dependencies + run: dnf install -y git zsh rsync + - uses: actions/checkout@v3 + - name: Install Bats + run: | + git clone --depth 1 https://github.com/bats-core/bats-core.git /tmp/bats-core + /tmp/bats-core/install.sh /usr/local + - name: Run tests + run: bats tests + + test-wsl: + runs-on: windows-2025 + defaults: + run: + shell: wsl-bash {0} + steps: + # Line-ending normalization is disabled in .gitattributes, and Windows + # runners default to core.autocrlf=true — without this, every shell + # file checks out with CRLF and breaks inside WSL. + - name: Force LF line endings on checkout + shell: pwsh + run: git config --global core.autocrlf false + - uses: actions/checkout@v3 + - uses: Vampire/setup-wsl@v5 + with: + distribution: Ubuntu-24.04 + additional-packages: bats zsh rsync + - name: Run tests + run: bats tests From 55c3ee09e930c871d46d06a479e0a115a5e5f7b1 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Sun, 12 Jul 2026 19:31:46 -0500 Subject: [PATCH 10/10] =?UTF-8?q?=E2=9C=85=20test:=20execute=20.macos=20en?= =?UTF-8?q?d-to-end=20on=20the=20throwaway=20macOS=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runners are ephemeral VMs, so mutating system preferences there is free — .macos now actually runs in the macOS job and a sample of settings is asserted via defaults read, instead of only grepping the script text. Double-guarded (Darwin + MACOS_APPLY_OK=1) so a plain `bats tests` on a real Mac always skips it. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yml | 3 +++ CLAUDE.md | 6 +++++- tests/macos-apply.bats | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/macos-apply.bats diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 73d047a174e..1b4cc67ccfb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,6 +23,9 @@ jobs: run: brew install bats-core - name: Run tests run: bats tests + - name: Apply .macos on the throwaway runner and verify + if: matrix.os == 'macos-latest' + run: MACOS_APPLY_OK=1 bats tests/macos-apply.bats test-rocky: # No hosted Rocky runners exist; run in a container on the ubuntu host. diff --git a/CLAUDE.md b/CLAUDE.md index c62394ab9c7..c397cbf0a71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,11 @@ bats tests/macos.bats bats tests/bootstrap.bats ``` -CI runs tests on both `ubuntu-latest` and `macos-latest` via `.github/workflows/tests.yml`. +CI (`.github/workflows/tests.yml`) runs the suite on `ubuntu-latest`, +`macos-latest`, Rocky Linux 9 (container), and WSL Ubuntu (windows runner). +The macOS job additionally executes `.macos` for real and asserts settings +stuck (`tests/macos-apply.bats`) — safe there because runners are throwaway +VMs; the test skips everywhere else unless `MACOS_APPLY_OK=1` is set. ## Architecture diff --git a/tests/macos-apply.bats b/tests/macos-apply.bats new file mode 100644 index 00000000000..ca7ca983686 --- /dev/null +++ b/tests/macos-apply.bats @@ -0,0 +1,32 @@ +#!/usr/bin/env bats + +# Executes .macos for real and asserts a sample of settings stuck. This +# mutates system preferences, so it must only run on a throwaway machine — +# CI runners are ephemeral VMs. Guarded twice (macOS only, plus an explicit +# MACOS_APPLY_OK opt-in) so a plain `bats tests` on a real Mac never +# triggers it. + +setup() { + [ "$(uname)" = "Darwin" ] || skip "macOS only" + [ "$MACOS_APPLY_OK" = "1" ] || skip "set MACOS_APPLY_OK=1 to run (mutates system preferences)" +} + +@test ".macos runs to completion" { + cd "$BATS_TEST_DIRNAME/.." + # Log to a file rather than letting bats capture output: the script's + # background sudo keep-alive inherits a captured pipe and would hold it + # open for up to 60s after exit. + ./.macos > "$BATS_TEST_TMPDIR/macos.log" 2>&1 +} + +@test "natural scrolling is disabled" { + [ "$(defaults read NSGlobalDomain com.apple.swipescrolldirection)" = "0" ] +} + +@test "screenshot location is set to Desktop" { + [ "$(defaults read com.apple.screencapture location)" = "$HOME/Desktop" ] +} + +@test "highlight color is set" { + [ "$(defaults read NSGlobalDomain AppleHighlightColor)" = "0.764700 0.976500 0.568600" ] +}