diff --git a/.aliases b/.aliases index 6a6deb228fc..ac6863b1b14 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' @@ -84,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/.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/.functions b/.functions index c3996a1749f..51855f7e388 100644 --- a/.functions +++ b/.functions @@ -220,3 +220,23 @@ 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; + + [[ $(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; + [[ $(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; +} 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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68206fe85cb..1b4cc67ccfb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,3 +23,41 @@ 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. + 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 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 diff --git a/CLAUDE.md b/CLAUDE.md index 4ea5cc9f59b..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 @@ -60,21 +64,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: +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 `gpg.format = ssh`, - `gpg.ssh.program = .../op-ssh-sign`, and `user.signingkey = ~/.ssh/*.pub`. +- `~/.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/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 diff --git a/bootstrap.sh b/bootstrap.sh index cc9682918b3..473cc0e6b43 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,13 +22,28 @@ function doIt() { "${WSL_DISTRO_NAME:+, WSL: $WSL_DISTRO_NAME}" \ "${TMUX:+, tmux}" > ~/.claude/CLAUDE.local.md; fi; - source ~/.zshrc; + # 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; + 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"; 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/"; + 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 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/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" ] +} 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"* ]] +}