From 63dcd5b7d3d30012b524c93b5204582cc685834e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 17:50:48 +0000 Subject: [PATCH 1/7] Replace chezmoi with Makefile + stow setup - Rename dot_* files to home/.* for direct stow management; drop the four run_once_*.sh.tmpl chezmoi scripts in favour of always-idempotent modules in macos/. - Split system config into macos/defaults.sh (system-wide), macos/dock.sh (dock layout), and macos/apps.sh (per-app prefs). All re-runnable. - Move helper functions to lib/dock_operations.sh, reference via $DOTFILES_DIR so the dock script works regardless of CWD. - New top-level Makefile is the single entry point: make install / update / brew / stow / fonts / macos / apps / doctor / brew-dump / adopt. - scripts/bootstrap.sh installs Xcode CLT, Rosetta (Apple Silicon) and Homebrew if missing. scripts/doctor.sh sanity-checks the environment. - Brewfile audit: drop chezmoi, nvm, pipx (unused or replaced); add bash, stow, gnu-sed (gsed), gnupg (gpg-connect-agent), node, and bun (via the oven-sh/bun tap). Reorganise into commented sections. - bin/ holds personal scripts on $PATH. First inhabitant: eod, an end-of-day report listing uncommitted/unpushed work across every git repo under ~/Developer. - .zshrc: drop chezmoi completion, expose DOTFILES_DIR, prepend bin/ to PATH, add a `dotfiles` alias that proxies to make from anywhere. - Add .gitignore for .DS_Store and Brewfile.lock.json; remove the committed copies. Drop the dead iTerm2 chezmoi symlink (Ghostty now). - Rewrite README with the new layout, target table and conventions. --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 2 + Brewfile | 82 +- Brewfile.lock.json | 1012 ----------------- Makefile | 80 ++ README.md | 117 +- bin/eod | 102 ++ dot_config/iterm2/private_sockets/.keep | 0 dot_config/iterm2/symlink_AppSupport | 1 - fonts/.DS_Store | Bin 8196 -> 0 bytes .../.config/nvim/.stylua.toml | 0 {dot_config => home/.config}/nvim/init.lua | 0 .../.config}/nvim/lazy-lock.json | 0 .../.config}/nvim/lua/custom/plugins/init.lua | 0 .../.config}/nvim/lua/kickstart/health.lua | 0 .../nvim/lua/kickstart/plugins/autopairs.lua | 0 .../nvim/lua/kickstart/plugins/debug.lua | 0 .../nvim/lua/kickstart/plugins/gitsigns.lua | 0 .../lua/kickstart/plugins/indent_line.lua | 0 .../nvim/lua/kickstart/plugins/lint.lua | 0 .../nvim/lua/kickstart/plugins/neo-tree.lua | 0 dot_gitconfig => home/.gitconfig | 0 .../.gitignore_global | 0 dot_p10k.zsh => home/.p10k.zsh | 0 {dot_ssh => home/.ssh}/config | 0 dot_zshrc => home/.zshrc | 67 +- dock_operations.sh => lib/dock_operations.sh | 0 macos/apps.sh | 80 ++ macos/defaults.sh | 94 ++ macos/dock.sh | 44 + run_once_after_configure-apps-darwin.sh.tmpl | 101 -- run_once_before_1-prepare-system.sh.tmpl | 7 - ...e_before_2-configure-system-darwin.sh.tmpl | 142 --- ...e_before_3-install-packages-darwin.sh.tmpl | 21 - scripts/bootstrap.sh | 46 + scripts/doctor.sh | 70 ++ 36 files changed, 726 insertions(+), 1342 deletions(-) delete mode 100644 .DS_Store create mode 100644 .gitignore delete mode 100644 Brewfile.lock.json create mode 100644 Makefile create mode 100755 bin/eod delete mode 100644 dot_config/iterm2/private_sockets/.keep delete mode 100644 dot_config/iterm2/symlink_AppSupport delete mode 100644 fonts/.DS_Store rename dot_config/nvim/dot_stylua.toml => home/.config/nvim/.stylua.toml (100%) rename {dot_config => home/.config}/nvim/init.lua (100%) rename {dot_config => home/.config}/nvim/lazy-lock.json (100%) rename {dot_config => home/.config}/nvim/lua/custom/plugins/init.lua (100%) rename {dot_config => home/.config}/nvim/lua/kickstart/health.lua (100%) rename {dot_config => home/.config}/nvim/lua/kickstart/plugins/autopairs.lua (100%) rename {dot_config => home/.config}/nvim/lua/kickstart/plugins/debug.lua (100%) rename {dot_config => home/.config}/nvim/lua/kickstart/plugins/gitsigns.lua (100%) rename {dot_config => home/.config}/nvim/lua/kickstart/plugins/indent_line.lua (100%) rename {dot_config => home/.config}/nvim/lua/kickstart/plugins/lint.lua (100%) rename {dot_config => home/.config}/nvim/lua/kickstart/plugins/neo-tree.lua (100%) rename dot_gitconfig => home/.gitconfig (100%) rename dot_gitignore_global => home/.gitignore_global (100%) rename dot_p10k.zsh => home/.p10k.zsh (100%) rename {dot_ssh => home/.ssh}/config (100%) rename dot_zshrc => home/.zshrc (67%) rename dock_operations.sh => lib/dock_operations.sh (100%) create mode 100755 macos/apps.sh create mode 100755 macos/defaults.sh create mode 100755 macos/dock.sh delete mode 100644 run_once_after_configure-apps-darwin.sh.tmpl delete mode 100644 run_once_before_1-prepare-system.sh.tmpl delete mode 100644 run_once_before_2-configure-system-darwin.sh.tmpl delete mode 100644 run_once_before_3-install-packages-darwin.sh.tmpl create mode 100755 scripts/bootstrap.sh create mode 100755 scripts/doctor.sh diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index a77e0259bc9a601311b54a120bf41d0573661608..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5Z>*NO(;SSDg`eAJr-;#trRb@))z2E4=S}W)dtgSX;N}1g}i{ij)-?3 z!RK-Ir_vh5n--aY*>84sCY${>>}Cidv^8-}LP~@X4U}U>hvp06<6KjcQJy>?70(Er zI2<`Xb5gPBSWO1_-sQLcr#=1kW)PS_%`1FaknlDxgfIs>Gl&9sGuhvlJ#0 zWjdj%_@HWLswxyJW{2k+BAif5BDTl?GO)}*R(4DL{y+J?{$EbQ9x{Lo{3`}%u5Gtl zuq1W2rWVKVS_AYNlnakbBz~lTA+BPG [!WARNING] -> **If you don't know what these do, don't just install them!** -> These are configuration files for my specific way of working. They setup many things - including 1Password for SSH/Git keys. Just installing these without research *will* result in a headache! +> These are tailored to my workflow (1Password for SSH/Git signing, Ghostty as +> terminal, Powerlevel10k, etc). Don't apply them blindly to your own machine. -## Getting up to speed -The two-liner to install these and get going is this: -```zsh -export GITHUB_USERNAME=limegorilla -sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply $GITHUB_USERNAME +## Quick start + +On a fresh Mac: + +```sh +git clone git@github.com:limegorilla/dotfiles.git ~/.dotfiles +cd ~/.dotfiles +make install ``` + +If you don't have git yet, the bootstrap script will install the Xcode Command +Line Tools first; run `make install` again afterwards. + +On an existing Mac, to re-apply everything safely: + +```sh +dotfiles update # available once .zshrc is sourced +# or, before the alias is loaded: +make -C ~/.dotfiles update +``` + +## Layout + +``` +dotfiles/ +├── Makefile # all entry points (make help for the list) +├── Brewfile # single source of truth for every installed app/CLI +├── home/ # stowed into $HOME (`make stow`) +│ ├── .zshrc +│ ├── .gitconfig +│ ├── .gitignore_global +│ ├── .p10k.zsh +│ ├── .ssh/config +│ └── .config/nvim/ +├── macos/ # idempotent macOS configuration scripts +│ ├── defaults.sh # global system defaults (Finder, firewall, ...) +│ ├── dock.sh # dock layout +│ └── apps.sh # per-app preferences (Safari, Mail, ...) +├── bin/ # personal scripts, added to $PATH via .zshrc +│ └── eod # end-of-day git status across ~/Developer +├── lib/ # shared shell helpers +│ └── dock_operations.sh +├── scripts/ # one-shot helpers used by the Makefile +│ ├── bootstrap.sh # Xcode CLT, Rosetta, Homebrew +│ └── doctor.sh # diagnose setup problems +└── fonts/ # bundled fonts, copied into ~/Library/Fonts +``` + +## Makefile targets + +Run `make help` for the live list. Highlights: + +| Target | What it does | +| --------------- | -------------------------------------------------------- | +| `make install` | Bootstrap, brew, stow, fonts, macos, apps (first run) | +| `make update` | Re-apply everything (safe to repeat on existing Macs) | +| `make brew` | `brew bundle` against the Brewfile | +| `make brew-dump`| Overwrite Brewfile with current brew state (review!) | +| `make stow` | Symlink `home/` into `$HOME` | +| `make restow` | Re-create symlinks (after pulling changes, for example) | +| `make adopt` | Move existing `$HOME` files **into** the repo (careful) | +| `make fonts` | Copy bundled fonts into `~/Library/Fonts` | +| `make macos` | `defaults.sh` + `dock.sh` | +| `make apps` | `apps.sh` | +| `make doctor` | Sanity-check the environment | + +Once `.zshrc` is sourced, the same targets are available via the `dotfiles` +alias (e.g. `dotfiles update`, `dotfiles doctor`). + +## The `bin/` directory + +Anything in `bin/` is on `$PATH` after the shell is sourced. Add a new script by +dropping a file in, marking it executable, and starting it with a useful +shebang (`#!/usr/bin/env bash` for shell, etc.). + +### `eod` — end-of-day report + +Walks every git repository under `~/Developer` and lists anything with +uncommitted changes, unpushed commits, or no upstream branch. Run it before +shutting down for the day: + +```sh +eod # scans ~/Developer +eod ~/code # scan a different tree +``` + +## Conventions + +- **Every package is installed via Brew or the Mac App Store.** Manual installs + (`curl | bash`, `npm i -g`, ...) belong in a script, not in folklore. +- **Every setup script is idempotent.** Re-running `make update` on an existing + Mac should never break the system or require manual cleanup. +- **macOS-only.** No `if linux` branches. If that changes, introduce + `linux/defaults.sh` and split the Makefile targets. + +## Adopting on an existing Mac + +If you already have a `~/.zshrc`, `~/.gitconfig`, etc., `make stow` will refuse +to overwrite them. Two options: + +1. Back them up, delete them, and `make stow`. +2. Run `make adopt` to **move** the existing files into `home/`, then review the + diff and reset anything you don't actually want to track. diff --git a/bin/eod b/bin/eod new file mode 100755 index 0000000..b297318 --- /dev/null +++ b/bin/eod @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# eod -- "end of day" git report. +# +# Walks every git repository under $DEVELOPER_DIR (default: ~/Developer) and +# reports anything that isn't yet committed or pushed, so nothing gets left +# behind at the end of the day. +# +# Usage: +# eod # scan ~/Developer +# eod ~/code # scan a different directory +# DEVELOPER_DIR=~/code eod # same, via env var + +set -euo pipefail + +ROOT="${1:-${DEVELOPER_DIR:-$HOME/Developer}}" + +if [[ ! -d "$ROOT" ]]; then + echo "eod: directory not found: $ROOT" >&2 + exit 1 +fi + +# Colours (disabled when stdout isn't a TTY) +if [[ -t 1 ]]; then + BOLD=$'\033[1m'; DIM=$'\033[2m' + YEL=$'\033[33m'; MAG=$'\033[35m'; CYA=$'\033[36m' + GRN=$'\033[32m'; RST=$'\033[0m' +else + BOLD=""; DIM=""; YEL=""; MAG=""; CYA=""; GRN=""; RST="" +fi + +# Collect repos (prune nested .git directories so we don't recurse into submodules) +repos=() +while IFS= read -r -d '' git_dir; do + repos+=("${git_dir%/.git}") +done < <(find "$ROOT" -name .git -type d -prune -print0 | sort -z) + +if [[ ${#repos[@]} -eq 0 ]]; then + echo "eod: no git repositories found under $ROOT" + exit 0 +fi + +dirty=0 +unpushed=0 +no_upstream=0 + +printf "${BOLD}End-of-day report${RST} ${DIM}(%s)${RST}\n\n" "$ROOT" + +for repo in "${repos[@]}"; do + if [[ "$repo" == "$HOME/"* ]]; then + display="~/${repo#$HOME/}" + else + display="$repo" + fi + cd "$repo" + + status="$(git status --porcelain 2>/dev/null || true)" + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" + + repo_has_issue=0 + repo_lines=() + + # Uncommitted work + if [[ -n "$status" ]]; then + file_count=$(printf '%s\n' "$status" | wc -l | tr -d ' ') + repo_lines+=(" ${YEL}● uncommitted${RST} ${file_count} file(s)") + dirty=$((dirty + 1)) + repo_has_issue=1 + fi + + # Unpushed commits (only meaningful with an upstream) + upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)" + if [[ -n "$upstream" ]]; then + ahead=$(git rev-list --count "@{u}..HEAD" 2>/dev/null || echo 0) + if [[ "$ahead" -gt 0 ]]; then + repo_lines+=(" ${MAG}↑ unpushed${RST} ${ahead} commit(s) on ${branch} (-> ${upstream})") + unpushed=$((unpushed + 1)) + repo_has_issue=1 + fi + else + # No upstream: only complain if there are commits beyond the initial state + if git rev-parse HEAD >/dev/null 2>&1 && [[ "$(git rev-list --count HEAD)" -gt 0 ]]; then + repo_lines+=(" ${CYA}↯ no upstream${RST} ${branch} has no remote tracking branch") + no_upstream=$((no_upstream + 1)) + repo_has_issue=1 + fi + fi + + if [[ $repo_has_issue -eq 1 ]]; then + printf "${BOLD}%s${RST}\n" "$display" + printf '%s\n' "${repo_lines[@]}" + echo + fi +done + +clean=$((${#repos[@]} - dirty - unpushed - no_upstream)) +printf "${DIM}Scanned %d repo(s):${RST}\n" "${#repos[@]}" +printf " ${YEL}%d${RST} with uncommitted changes\n" "$dirty" +printf " ${MAG}%d${RST} with unpushed commits\n" "$unpushed" +printf " ${CYA}%d${RST} on a branch with no upstream\n" "$no_upstream" +if [[ $dirty -eq 0 && $unpushed -eq 0 && $no_upstream -eq 0 ]]; then + printf "${GRN}All caught up. Have a good evening.${RST}\n" +fi diff --git a/dot_config/iterm2/private_sockets/.keep b/dot_config/iterm2/private_sockets/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/dot_config/iterm2/symlink_AppSupport b/dot_config/iterm2/symlink_AppSupport deleted file mode 100644 index 92c5be6..0000000 --- a/dot_config/iterm2/symlink_AppSupport +++ /dev/null @@ -1 +0,0 @@ -/Users/liamdoyle/Library/Application Support/iTerm2 diff --git a/fonts/.DS_Store b/fonts/.DS_Store deleted file mode 100644 index 033033a927f7f6aa33714f59d5eba46c2ac914a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMU2GIp6h3EK=)IPoX)RC~)~=QkEJfO4sRe{>yA+C)0^8Cb&}DXKkO|Y7vNOAj z5=avznh1^Y=TQlo_+SjtL=z){#KZ?N(FbUVMjwn1KAG_1gFo@-+_|%amiCE64bDyG zo;mlPbEoH<@7%j*3IN!ZH(CH{0f5mZq*_VcEKSUd>yD;`BV{C!`~iIE0~Z`{GsQcs zqk#y42!RNJ2!RNJ2!a0s0(54J#^yNpd9Z@ ztavhY)Gelb-OqbndhVdm23*(9lo@ODAG60C_neFe z!#x2O&WLYv_TX%<&@F>2TTu$`>o7RRd8IZqJTjVA)x2l!H*MXw3T9IGO)sr3_9)KY ztn2K}nFWe(DC^q!F^6WVvbvqkld;)p+bQc=(;N296TY|`_1v7_9T*N<a+&N4vO*aoJqYL=4-S@uIg;SqhRC`>-c)EdVJs*y<6$p${%H_ z!3S+UW7=}4op&(x2|iSi5zE0Xd@HNMCO({X$HpigPY!o#-Aw(IJtdyKTlf6_anmXN zRXy5Hrhd*wy`y^0>@O0%q)(5wo3Y?M(YSEhZdksX3n1B3r)eW%u};G<4T`*W=`vMI zX@g9?Mn0%C}ipLR}s#_Ihceu z;2pRKAHo&53SYpN@D*H#@8Adc34Vq@;4cJ3ybmjJAud7nTW~9G!zA|M z9^8xj@BpUqFlI1|7Us~yJQnaYzJM>{OL!Jv$2ajjUck5T1N;Op;bpvnSMeHN$0_^~ zZ{n}`8~(18EAx~Jg(;e{QfW{cl?^fqrIb4dr$?nENuOn7pxg;J2Icr{xfAQzv9tGI z$(?T&h-|5Lcr8TrH!d;(_mmMp7|U+8^Uw zEvNL7b6XxK6-35My|ar}63N2up0pR>U3ecphR=w!H{e_N9&W-f@F$iNNta?R5p^ZT zh^T9@5!Yf9wqZMV;3lGN5_@nb_Tnx~;UEs-Fpdyu4K(oxx_F$3>l1ZP;{-m3&*K@K z#Fz0Ed=*~{DEv0QH-nP9rc)9xPuC;uE)fuCKebv>|8JlB`+r~*brAv)0{12YSe{HJ zyC}BOZ-JR%ids8P*I~Lu6Lu3)1}@YE/dev/null && eval "$(op completion zsh)" && compdef _op op zinit cdreplay -q -# Zshell settings +# --------------------------------------------------------------------------------------------------------------------- +# History # --------------------------------------------------------------------------------------------------------------------- HISTSIZE=5000 HISTFILE=~/.zsh_history @@ -82,40 +80,45 @@ zstyle ':completion:*' menu no zstyle ':fzf-tab:complete:cd:*' fzf-preview 'ls --color $realpath' zstyle ':fzf-tab:complete:__zoxide_z:*' fzf-preview 'ls --color $realpath' +# --------------------------------------------------------------------------------------------------------------------- +# PATH (personal scripts live in $DOTFILES_DIR/bin) +# --------------------------------------------------------------------------------------------------------------------- +[[ -d "$DOTFILES_DIR/bin" ]] && export PATH="$DOTFILES_DIR/bin:$PATH" + # --------------------------------------------------------------------------------------------------------------------- # Aliases # --------------------------------------------------------------------------------------------------------------------- alias ls='ls --color' alias vim='nvim' alias c='clear' -alias ssh="TERM_SIMPLE=1 ssh" +alias ssh='TERM_SIMPLE=1 ssh' -# --------------------------------------------------------------------------------------------------------------------- -# Shell Intergrations -# --------------------------------------------------------------------------------------------------------------------- -eval "$(fzf --zsh)" -eval "$(zoxide init --cmd cd zsh)" +# Manage the dotfiles repo from anywhere: `dotfiles update`, `dotfiles brew`, etc. +alias dotfiles="make -C $DOTFILES_DIR" # --------------------------------------------------------------------------------------------------------------------- -# Chezmoi +# Shell integrations # --------------------------------------------------------------------------------------------------------------------- -command -v chezmoi >/dev/null && . <(chezmoi completion zsh) +command -v fzf >/dev/null && eval "$(fzf --zsh)" +command -v zoxide >/dev/null && eval "$(zoxide init --cmd cd zsh)" # --------------------------------------------------------------------------------------------------------------------- # Orbstack # --------------------------------------------------------------------------------------------------------------------- source ~/.orbstack/shell/init.zsh 2>/dev/null || : -# Additional +# --------------------------------------------------------------------------------------------------------------------- +# Editor +# --------------------------------------------------------------------------------------------------------------------- export EDITOR=nvim -# bun completions -[ -s "$HOME/.bun/_bun" ] && source "$HOME/.bun/_bun" - -# bun +# --------------------------------------------------------------------------------------------------------------------- +# Bun +# --------------------------------------------------------------------------------------------------------------------- export BUN_INSTALL="$HOME/.bun" +[ -s "$HOME/.bun/_bun" ] && source "$HOME/.bun/_bun" -# Added by LM Studio CLI (lms) +# --------------------------------------------------------------------------------------------------------------------- +# Extra PATH entries (Postgres@17, LM Studio, Bun) +# --------------------------------------------------------------------------------------------------------------------- export PATH="/opt/homebrew/opt/postgresql@17/bin:$PATH:$HOME/.lmstudio/bin:$BUN_INSTALL/bin" -# End of LM Studio CLI section - diff --git a/dock_operations.sh b/lib/dock_operations.sh similarity index 100% rename from dock_operations.sh rename to lib/dock_operations.sh diff --git a/macos/apps.sh b/macos/apps.sh new file mode 100755 index 0000000..54ec6e8 --- /dev/null +++ b/macos/apps.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Per-app configuration. Re-runnable. +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macos/apps.sh: not running on macOS, skipping." >&2 + exit 0 +fi + +echo "==> Configuring apps" + +# --------------------------------------------------------------------------------------------------------------------- +# Amphetamine +# --------------------------------------------------------------------------------------------------------------------- +killall Amphetamine >/dev/null 2>&1 || true +defaults write com.if.Amphetamine "Start Session At Launch" -bool true + +# --------------------------------------------------------------------------------------------------------------------- +# GPG +# --------------------------------------------------------------------------------------------------------------------- +if command -v gpg-connect-agent >/dev/null; then + gpg-connect-agent reloadagent /bye >/dev/null 2>&1 || true +fi + +# --------------------------------------------------------------------------------------------------------------------- +# Homebrew auto-update (weekly) +# --------------------------------------------------------------------------------------------------------------------- +mkdir -p ~/Library/LaunchAgents +PLIST=~/Library/LaunchAgents/com.github.domt4.homebrew-autoupdate.plist +if [[ ! -f "$PLIST" ]]; then + touch "$PLIST" +fi +if command -v brew >/dev/null && brew commands | grep -q autoupdate; then + brew autoupdate delete >/dev/null 2>&1 || true + brew autoupdate start 604800 --upgrade >/dev/null +fi + +# --------------------------------------------------------------------------------------------------------------------- +# Mail +# --------------------------------------------------------------------------------------------------------------------- +killall Mail >/dev/null 2>&1 || true +# Copy email addresses as `foo@example.com`, not `Foo Bar ` +defaults write com.apple.mail AddressesIncludeNameOnPasteboard -bool false + +# --------------------------------------------------------------------------------------------------------------------- +# Safari (privacy hardening) +# --------------------------------------------------------------------------------------------------------------------- +killall Safari >/dev/null 2>&1 || true +defaults write com.apple.Safari UniversalSearchEnabled -bool false +defaults write com.apple.Safari SuppressSearchSuggestions -bool true +defaults write com.apple.Safari SendDoNotTrackHTTPHeader -bool true +defaults write com.apple.Safari AutoFillFromAddressBook -bool false +defaults write com.apple.Safari AutoFillPasswords -bool false +defaults write com.apple.Safari AutoFillCreditCardData -bool false +defaults write com.apple.Safari AutoFillMiscellaneousForms -bool false +defaults write com.apple.Safari InstallExtensionUpdatesAutomatically -bool true + +# --------------------------------------------------------------------------------------------------------------------- +# Terminal.app +# --------------------------------------------------------------------------------------------------------------------- +defaults write com.apple.Terminal "Default Window Settings" -string Basic +defaults write com.apple.Terminal "Startup Window Settings" -string Basic +defaults write com.apple.terminal StringEncodings -array 4 +osascript -e 'tell application "Terminal" to set font name of settings set "Basic" to "MesloLGLNerdFontComplete-Regular"' >/dev/null 2>&1 || true +osascript -e 'tell application "Terminal" to set font size of settings set "Basic" to 18' >/dev/null 2>&1 || true + +# Required so zsh completion doesn't complain about insecure directories +if command -v brew >/dev/null; then + chmod -R go-w "$(brew --prefix)/share" || true +fi + +# --------------------------------------------------------------------------------------------------------------------- +# TextEdit +# --------------------------------------------------------------------------------------------------------------------- +killall TextEdit >/dev/null 2>&1 || true +defaults write com.apple.TextEdit RichText -bool false +defaults write com.apple.TextEdit PlainTextEncoding -int 4 +defaults write com.apple.TextEdit PlainTextEncodingForWrite -int 4 + +echo "==> Apps configured" diff --git a/macos/defaults.sh b/macos/defaults.sh new file mode 100755 index 0000000..9784ccc --- /dev/null +++ b/macos/defaults.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Idempotent macOS system defaults. +# Re-run any time with `make macos`. +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macos/defaults.sh: not running on macOS, skipping." >&2 + exit 0 +fi + +echo "==> Applying macOS defaults" + +# Close System Settings so it doesn't overwrite our changes +osascript -e 'tell application "System Settings" to quit' >/dev/null 2>&1 || true +osascript -e 'tell application "System Preferences" to quit' >/dev/null 2>&1 || true + +# --------------------------------------------------------------------------------------------------------------------- +# Global +# --------------------------------------------------------------------------------------------------------------------- +defaults write com.apple.appleseed.FeedbackAssistant Autogather -bool false +defaults write -g NSAutomaticCapitalizationEnabled -bool false +defaults write -g NSAutomaticDashSubstitutionEnabled -bool true +defaults -currentHost write com.apple.ImageCapture disableHotPlug -bool true +defaults write NSGlobalDomain PMPrintingExpandedStateForPrint -bool true +defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode -bool true + +# Tap-to-click +defaults write com.apple.driver.AppleBluetoothMultitouch.trackpad Clicking -bool true +defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 +defaults write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 + +# --------------------------------------------------------------------------------------------------------------------- +# Security +# --------------------------------------------------------------------------------------------------------------------- +# Enable Touch ID for sudo using /etc/pam.d/sudo_local (survives system updates on macOS 14+). +# Fall back to editing /etc/pam.d/sudo if sudo_local isn't supported. +if [[ -f /etc/pam.d/sudo_local.template ]]; then + if ! sudo grep -q 'pam_tid.so' /etc/pam.d/sudo_local 2>/dev/null; then + echo "Enabling Touch ID for sudo via /etc/pam.d/sudo_local" + sudo cp /etc/pam.d/sudo_local.template /etc/pam.d/sudo_local + sudo sed -i '' 's/^#\(auth.*pam_tid.so\)/\1/' /etc/pam.d/sudo_local + fi +elif ! sudo grep -q 'pam_tid.so' /etc/pam.d/sudo; then + echo "Enabling Touch ID for sudo via /etc/pam.d/sudo" + if ! command -v gsed >/dev/null; then + echo "ERROR: gsed (gnu-sed) is required. Install via 'brew install gnu-sed'." >&2 + exit 1 + fi + sudo gsed -i '2iauth sufficient pam_tid.so' /etc/pam.d/sudo +fi + +# Secure keyboard entry in Terminal-likes +defaults write -app Terminal SecureKeyboardEntry -bool true +defaults write -app iTerm SecureKeyboardEntry -bool true + +# Require an administrator password to access system-wide preferences +# https://www.tenable.com/audits/CIS_Apple_macOS_11_v2.0.0_L1 +TMP_PLIST=$(mktemp -t system.preferences.plist) +sudo security authorizationdb read system.preferences > "$TMP_PLIST" +sudo defaults write "$TMP_PLIST" shared -bool false +sudo security authorizationdb write system.preferences < "$TMP_PLIST" +rm -f "$TMP_PLIST" + +# --------------------------------------------------------------------------------------------------------------------- +# Finder +# --------------------------------------------------------------------------------------------------------------------- +defaults write com.apple.Finder AppleShowAllFiles -bool false +defaults write com.apple.finder QLEnableTextSelection -bool true +defaults write com.apple.finder FXDefaultSearchScope -string "SCcf" +defaults write com.apple.finder FXPreferredViewStyle -string "Nlsv" +defaults write com.apple.finder SearchRecentsSavedViewStyle -string "Nlsv" +defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true +defaults write com.apple.desktopservices DSDontWriteUSBStores -bool true +defaults write com.apple.finder EmptyTrashSecurely -bool true +killall Finder >/dev/null 2>&1 || true + +# --------------------------------------------------------------------------------------------------------------------- +# Firewall +# --------------------------------------------------------------------------------------------------------------------- +sudo defaults write /Library/Preferences/com.apple.alf globalstate -int 1 +sudo defaults write /Library/Preferences/com.apple.alf stealthenabled -int 1 + +# --------------------------------------------------------------------------------------------------------------------- +# Software Update +# --------------------------------------------------------------------------------------------------------------------- +sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool true +sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool true +sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -bool true +sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate ConfigDataInstall -bool true +sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate CriticalUpdateInstall -bool true +sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate ScheduleFrequency -int 1 +defaults write com.apple.commerce AutoUpdate -bool true + +echo "==> macOS defaults applied" diff --git a/macos/dock.sh b/macos/dock.sh new file mode 100755 index 0000000..0f0a5e7 --- /dev/null +++ b/macos/dock.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Configure the Dock. Re-runnable: clears the dock then rebuilds it deterministically. +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macos/dock.sh: not running on macOS, skipping." >&2 + exit 0 +fi + +# Resolve repo dir (so we can source the dock helpers regardless of CWD) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="${DOTFILES_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +# shellcheck source=../lib/dock_operations.sh +source "$REPO_DIR/lib/dock_operations.sh" + +echo "==> Configuring Dock" + +defaults write com.apple.dock orientation -string "bottom" +defaults write com.apple.dock tilesize -int 43 +defaults write com.apple.dock minimize-to-application -bool false +defaults write com.apple.dock launchanim -bool true +defaults write com.apple.dock show-process-indicators -bool true +defaults write com.apple.dock show-recents -bool false + +clear_dock + +apps=( + '/System/Cryptexes/App/System/Applications/Safari.app' + '/Applications/Visual Studio Code.app' + '/Applications/Ghostty.app' + '/System/Applications/Mail.app' + '/System/Applications/Calendar.app' + '/System/Applications/Music.app' +) +for app in "${apps[@]}"; do + add_app_to_dock "$app" +done + +add_folder_to_dock "$HOME/Downloads" --arrangement 3 --displayAs 0 --showAs 1 +add_folder_to_dock "$HOME/Developer" --arrangement 1 --displayAs 1 --showAs 2 + +killall Dock >/dev/null 2>&1 || true +echo "==> Dock configured" diff --git a/run_once_after_configure-apps-darwin.sh.tmpl b/run_once_after_configure-apps-darwin.sh.tmpl deleted file mode 100644 index 52b50a4..0000000 --- a/run_once_after_configure-apps-darwin.sh.tmpl +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash -{{ if (eq .chezmoi.os "darwin") -}} - -# --------------------------------------------------------------------------------------------------------------------- -# Amphetamine settings -# --------------------------------------------------------------------------------------------------------------------- -killall Amphetamine -# Start session as soon as Amphetamine starts -defaults write com.if.Amphetamine "Start Session At Launch" -bool true - -# # --------------------------------------------------------------------------------------------------------------------- -# # Docker settings -# # --------------------------------------------------------------------------------------------------------------------- -# # Disable automatic updates -# killall Docker -# defaults write com.docker.docker SUAutomaticallyUpdate -bool false -# defaults write com.docker.docker SUEnableAutomaticChecks -bool false -# open -a Docker - -# --------------------------------------------------------------------------------------------------------------------- -# GPG settings -# --------------------------------------------------------------------------------------------------------------------- -gpg-connect-agent reloadagent /bye - -# --------------------------------------------------------------------------------------------------------------------- -# Homebrew settings -# --------------------------------------------------------------------------------------------------------------------- -# https://github.com/Homebrew/homebrew-autoupdate/issues/10 -mkdir -p ~/Library/LaunchAgents -# https://github.com/Homebrew/homebrew-autoupdate/issues/14 -sudo touch ~/Library/LaunchAgents/com.github.domt4.homebrew-autoupdate.plist -sudo chown $(whoami) ~/Library/LaunchAgents/com.github.domt4.homebrew-autoupdate.plist -# Auto-upgrade apps every week -echo "Deleting any autoupdate plist before enabling autoupdate" -brew autoupdate delete && brew autoupdate start 604800 --upgrade - -# --------------------------------------------------------------------------------------------------------------------- -# Mail settings -# --------------------------------------------------------------------------------------------------------------------- -killall Mail -# Copy email addresses as `foo@example.com` instead of `Foo Bar ` in Mail.app -defaults write com.apple.mail AddressesIncludeNameOnPasteboard -bool false - -# --------------------------------------------------------------------------------------------------------------------- -# OneDrive settings -# --------------------------------------------------------------------------------------------------------------------- -# iPhone backups are located in OneDrive -# TODO: Add in Sharepoint shares -# mkdir -p ~/Library/Application\ Support/MobileSync -# ln -s ~/Library/CloudStorage/OneDrive-Personal/iPhone\ backup ~/Library/Application\ Support/MobileSync/Backup - -# --------------------------------------------------------------------------------------------------------------------- -# Safari settings -# --------------------------------------------------------------------------------------------------------------------- -killall Safari -# Privacy: don’t send search queries to Apple -defaults write com.apple.Safari UniversalSearchEnabled -bool false -defaults write com.apple.Safari SuppressSearchSuggestions -bool true -# Privacy: Enable “Do Not Track” -defaults write com.apple.Safari SendDoNotTrackHTTPHeader -bool true -# Disable AutoFill -defaults write com.apple.Safari AutoFillFromAddressBook -bool false -defaults write com.apple.Safari AutoFillPasswords -bool false -defaults write com.apple.Safari AutoFillCreditCardData -bool false -defaults write com.apple.Safari AutoFillMiscellaneousForms -bool false -# Update extensions automatically -defaults write com.apple.Safari InstallExtensionUpdatesAutomatically -bool true - -# --------------------------------------------------------------------------------------------------------------------- -# Terminal settings -# --------------------------------------------------------------------------------------------------------------------- -# Make sure Terminal is using the Basic profile -defaults write com.apple.Terminal "Default Window Settings" -string Basic -defaults write com.apple.Terminal "Startup Window Settings" -string Basic -# Only use UTF-8 -defaults write com.apple.terminal StringEncodings -array 4 -# Set font preferences -# To get the current font name, use: osascript -e "tell application \"Terminal\" to get the font name of window 1" -osascript -e "tell application \"Terminal\" to set font name of settings set \"Basic\" to \"MesloLGLNerdFontComplete-Regular\"" -osascript -e "tell application \"Terminal\" to set font size of settings set \"Basic\" to 18" -# https://docs.brew.sh/Shell-Completion#configuring-completions-in-zsh -chmod -R go-w "$(brew --prefix)/share" - -# --------------------------------------------------------------------------------------------------------------------- -# TextEdit settings -# --------------------------------------------------------------------------------------------------------------------- -killall TextEdit -# Set default TextEdit document format as plain text -defaults write com.apple.TextEdit "RichText" -bool "false" -# Open and save files as UTF-8 -defaults write com.apple.TextEdit PlainTextEncoding -int 4 -defaults write com.apple.TextEdit PlainTextEncodingForWrite -int 4 - -# --------------------------------------------------------------------------------------------------------------------- -# Make chezmoi use Git with SSH -# --------------------------------------------------------------------------------------------------------------------- -cd "{{ .chezmoi.sourceDir }}" -CHEZMOI_SSH_URL=$(git remote get-url origin | sed -Ene's#https://([^/]*)/([^/]*/.*.git)#git@\1:\2#p') -[[ -z $CHEZMOI_SSH_URL ]] || git remote set-url origin $CHEZMOI_SSH_URL - -{{ end -}} \ No newline at end of file diff --git a/run_once_before_1-prepare-system.sh.tmpl b/run_once_before_1-prepare-system.sh.tmpl deleted file mode 100644 index 36de09f..0000000 --- a/run_once_before_1-prepare-system.sh.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# Install Rosetta for Apple Silicon // ARM64 platforms -{{ if (and (eq .chezmoi.os "darwin") (eq .chezmoi.arch "arm64")) }} -echo "INSTALLING ROSETTA - PASSWORD MAY BE REQUIRED" -softwareupdate --install-rosetta --agree-to-license -{{ end }} diff --git a/run_once_before_2-configure-system-darwin.sh.tmpl b/run_once_before_2-configure-system-darwin.sh.tmpl deleted file mode 100644 index 1e6df0b..0000000 --- a/run_once_before_2-configure-system-darwin.sh.tmpl +++ /dev/null @@ -1,142 +0,0 @@ -#!/bin/bash -{{ if (eq .chezmoi.os "darwin") -}} - -# Inspired from https://github.com/politician/dotfiles/blob/main/run_once_before_2-configure-system-darwin.sh.tmpl - -# Force System prefrences to quit -osascript -e 'tell application "System Preferences" to quit' -# Source dock functions -source "./dock_operations.sh" - -# --------------------------------------------------------------------------------------------------------------------- -# Global settings -# --------------------------------------------------------------------------------------------------------------------- - -echo "Modifying macOS Global Settings" - -# Do not autogather large files when submitting a feedback report -defaults write com.apple.appleseed.FeedbackAssistant "Autogather" -bool "false" -# Disable automatic capitalization -defaults write -g NSAutomaticCapitalizationEnabled -bool false -# Enable smart dashes -defaults write -g NSAutomaticDashSubstitutionEnabled -bool true -# Prevent Photos from opening automatically when devices are plugged in -defaults -currentHost write com.apple.ImageCapture disableHotPlug -bool true && killall Photos -# Save/Print modals are auto-expanded by default -defaults write NSGlobalDomain PMPrintingExpandedStateForPrint -bool true -defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode -bool true -# Trackpad: enable tap to click for this user and for the login screen -defaults write com.apple.driver.AppleBluetoothMultitouch.trackpad Clicking -bool true -defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 -defaults write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 - -# --------------------------------------------------------------------------------------------------------------------- -# Security settings -# --------------------------------------------------------------------------------------------------------------------- -# Enable TouchID for sudo -! grep -q pam_tid.so /etc/pam.d/sudo && sudo gsed -i '2iauth sufficient pam_tid.so' /etc/pam.d/sudo -# Ensure Secure Keyboard Entry in Terminal is enabled -defaults write -app Terminal SecureKeyboardEntry -bool true -defaults write -app iTerm SecureKeyboardEntry -bool true - -# --------------------------------------------------------------------------------------------------------------------- -# AuthorizationDB settings -# The authorizationdb settings cannot be written to directly, so the plist must be exported out to temporary file -# --------------------------------------------------------------------------------------------------------------------- -# Export AuthorizationDB settings to temporary file -sudo security authorizationdb read system.preferences > /tmp/system.preferences.plist - -# Require an administrator password to access system-wide preferences -# https://www.tenable.com/audits/CIS_Apple_macOS_11_v2.0.0_L1 -sudo defaults write /tmp/system.preferences.plist shared -bool false - -# Import AuthorizationDB settings from temporary file -sudo security authorizationdb write system.preferences < /tmp/system.preferences.plist - -# --------------------------------------------------------------------------------------------------------------------- -# Dock settings -# --------------------------------------------------------------------------------------------------------------------- -# Set dock position -defaults write com.apple.dock orientation -string "bottom" -# Set the icon size of Dock items in pixels -defaults write com.apple.dock "tilesize" -int 43 -# Minimize windows into their application’s icon -defaults write com.apple.dock minimize-to-application -bool false -# Enable launch animation -defaults write com.apple.dock launchanim -bool true -# Show indicator lights for open applications in the Dock -defaults write com.apple.dock show-process-indicators -bool true -# Don’t show recent applications in Dock -defaults write com.apple.dock show-recents -bool false -# Clear Dock -clear_dock -# Declare apps to set to dock -declare -a apps=( - '/System/Cryptexes/App/System/Applications/Safari.app' - '/Applications/Visual Studio Code.app' - '/Applications/Ghostty.app' - '/System/Applications/Mail.app' - '/System/Applications/Calendar.app' - '/System/Applications/Music.app' -); -# Add apps to dock -for app in "${apps[@]}"; do - add_app_to_dock "$app" -done -# Add folders to dock (not done in an array as I need to pass options to add_folder_to_dock) -add_folder_to_dock "$HOME/Downloads" --arrangement 3 --displayAs 0 --showAs 1 -add_folder_to_dock "$HOME/Developer" --arrangement 1 --displayAs 1 --showAs 2 -# Kill Dock -killall Dock - -# --------------------------------------------------------------------------------------------------------------------- -# Finder settings -# --------------------------------------------------------------------------------------------------------------------- -# Do not show hidden files -defaults write com.apple.Finder "AppleShowAllFiles" -bool "false" -# Allowing text selection in Quick Look/Preview in Finder by default -defaults write com.apple.finder QLEnableTextSelection -bool true -# When performing a search, search the current folder by default (WHY IS THIS NOT THE DEFAULT?!) -defaults write com.apple.finder FXDefaultSearchScope -string "SCcf" -# Use list view in all Finder windows by default -defaults write com.apple.finder FXPreferredViewStyle -string "Nlsv" -defaults write com.apple.finder SearchRecentsSavedViewStyle -string "Nlsv" -# Disable creation of metadata files on external volumes -defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true -defaults write com.apple.desktopservices DSDontWriteUSBStores -bool true -# Empty Trash securely by default -defaults write com.apple.finder EmptyTrashSecurely -bool true - -killall Finder - -# --------------------------------------------------------------------------------------------------------------------- -# Firewall settings -# --------------------------------------------------------------------------------------------------------------------- -# Enable firewall -sudo defaults write /Library/Preferences/com.apple.alf globalstate -int 1 -sudo defaults write /Library/Preferences/com.apple.alf stealthenabled -int 1 - -#launchctl unload /System/Library/LaunchAgents/com.apple.alf.useragent.plist -#sudo launchctl unload /System/Library/LaunchDaemons/com.apple.alf.agent.plist -#sudo launchctl load /System/Library/LaunchDaemons/com.apple.alf.agent.plist -#launchctl load /System/Library/LaunchAgents/com.apple.alf.useragent.plist - -# --------------------------------------------------------------------------------------------------------------------- -# Software Update settings -# --------------------------------------------------------------------------------------------------------------------- -# Automatically check for updates -sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool true -# Download updates automatically in the background -sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool true -# Install macos updates automatically -sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -bool true -# Install system data file updates automatically -sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate ConfigDataInstall -bool true -# Install critical security updates automatically -sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate CriticalUpdateInstall -bool true -# Check for software updates daily, not just once per week -sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate ScheduleFrequency -int 1 -# Install app updates automatically -defaults write com.apple.commerce AutoUpdate -bool true - -{{ end -}} diff --git a/run_once_before_3-install-packages-darwin.sh.tmpl b/run_once_before_3-install-packages-darwin.sh.tmpl deleted file mode 100644 index 32bfa9b..0000000 --- a/run_once_before_3-install-packages-darwin.sh.tmpl +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -{{ if (eq .chezmoi.os "darwin") -}} - -# Close App Store apps in case this script updates them (this should be anything mas installs from the brewfile) -killall "1Password for Safari" "Amphetamine" "Apple Configurator" "Broadcasts" "Codye" "Compressor" "Craft" "Final Cut Pro" "GarageBand" "iMovie" "Keynote" "Logic Pro" "Microsoft Excel" "Microsoft Word" "Motion" "MusicHarbor" "Numbers" "OneDrive" "Pages" "Playgrounds" "Reeder" "Speediness" "Xcode" "WhatsApp" - -brew bundle - -{{ end -}} - -# Install latest stable version of Node.js -volta install node - -# Install latest versions of global Node.js packages -npm install -g\ - prettier@latest\ - eslint@latest\ - typescript@latest\ - turbo@latest\ - yarn@latest\ - pnpm@latest \ No newline at end of file diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..81ef45f --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Install Xcode Command Line Tools and Homebrew if they're not already present. +# Safe to re-run. +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "scripts/bootstrap.sh: not running on macOS, skipping." >&2 + exit 0 +fi + +# --------------------------------------------------------------------------------------------------------------------- +# Xcode Command Line Tools +# --------------------------------------------------------------------------------------------------------------------- +if ! xcode-select -p >/dev/null 2>&1; then + echo "==> Installing Xcode Command Line Tools (a GUI prompt will appear)" + xcode-select --install + echo " Re-run 'make install' once the Command Line Tools finish installing." + exit 1 +fi + +# --------------------------------------------------------------------------------------------------------------------- +# Rosetta 2 (Apple Silicon only) +# --------------------------------------------------------------------------------------------------------------------- +if [[ "$(uname -m)" == "arm64" ]]; then + if ! /usr/bin/pgrep -q oahd; then + echo "==> Installing Rosetta 2" + softwareupdate --install-rosetta --agree-to-license + fi +fi + +# --------------------------------------------------------------------------------------------------------------------- +# Homebrew +# --------------------------------------------------------------------------------------------------------------------- +if ! command -v brew >/dev/null; then + echo "==> Installing Homebrew" + NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +fi + +# Add brew to PATH for the current shell (also handled by .zshrc afterwards) +if [[ -x /opt/homebrew/bin/brew ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" +elif [[ -x /usr/local/bin/brew ]]; then + eval "$(/usr/local/bin/brew shellenv)" +fi + +echo "==> Bootstrap complete" diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..aef903f --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Diagnose obvious setup problems. +set -euo pipefail + +PASS="\033[32m✓\033[0m" +FAIL="\033[31m✗\033[0m" +WARN="\033[33m!\033[0m" + +problems=0 + +check() { + local label="$1" + local cmd="$2" + if eval "$cmd" >/dev/null 2>&1; then + printf "$PASS %s\n" "$label" + else + printf "$FAIL %s\n" "$label" + problems=$((problems + 1)) + fi +} + +warn_if_missing() { + local label="$1" + local cmd="$2" + if eval "$cmd" >/dev/null 2>&1; then + printf "$PASS %s\n" "$label" + else + printf "$WARN %s\n" "$label" + fi +} + +echo "Required tools" +check "brew installed" "command -v brew" +check "stow installed" "command -v stow" +check "gsed (gnu-sed) installed" "command -v gsed" +check "gh (GitHub CLI) installed" "command -v gh" +check "nvim installed" "command -v nvim" +check "delta installed" "command -v delta" + +echo +echo "Optional tools" +warn_if_missing "node installed" "command -v node" +warn_if_missing "bun installed" "command -v bun" +warn_if_missing "pnpm installed" "command -v pnpm" +warn_if_missing "op (1Password CLI)" "command -v op" + +echo +echo "Symlinks" +for f in ~/.zshrc ~/.gitconfig ~/.p10k.zsh ~/.gitignore_global ~/.ssh/config ~/.config/nvim/init.lua; do + if [[ -L "$f" ]]; then + printf "$PASS %s -> %s\n" "$f" "$(readlink "$f")" + elif [[ -e "$f" ]]; then + printf "$WARN %s exists but is not a symlink\n" "$f" + else + printf "$FAIL %s missing\n" "$f" + problems=$((problems + 1)) + fi +done + +echo +echo "Filesystem" +warn_if_missing "~/Developer exists" "[[ -d $HOME/Developer ]]" + +echo +if [[ $problems -eq 0 ]]; then + printf "$PASS No problems found.\n" +else + printf "$FAIL %d problem(s) found.\n" "$problems" + exit 1 +fi From ee49b88a3b76dd11fbbcf040663a2c97cc83b718 Mon Sep 17 00:00:00 2001 From: Liam Doyle Date: Mon, 18 May 2026 19:46:30 +0100 Subject: [PATCH 2/7] deps --- Brewfile | 64 ++------------------------------------------------------ 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/Brewfile b/Brewfile index 5fdf992..cef15b7 100644 --- a/Brewfile +++ b/Brewfile @@ -6,12 +6,8 @@ # --------------------------------------------------------------------------------------------------------------------- # Taps # --------------------------------------------------------------------------------------------------------------------- -tap "homebrew/bundle" -tap "homebrew/services" -tap "mongodb/brew" tap "oven-sh/bun" tap "planetscale/tap" -tap "supabase/tap" # --------------------------------------------------------------------------------------------------------------------- # CLI tools and language runtimes (brew) @@ -32,7 +28,6 @@ brew "fzf" brew "gh" brew "git-delta" brew "lazygit" -brew "lftp" brew "mactop" brew "mas" brew "minicom" @@ -43,7 +38,6 @@ brew "ripgrep" brew "sherlock" brew "sniffnet" brew "sox" -brew "testdisk" brew "tmux" brew "wakatime-cli" brew "wget" @@ -52,7 +46,6 @@ brew "zoxide" # Web / cloud brew "caddy" -brew "flyctl" brew "vercel-cli" # Media tooling @@ -62,22 +55,14 @@ brew "ffmpeg" brew "cocoapods" # Language runtimes -brew "deno" brew "node" -brew "oven-sh/bun/bun" brew "pnpm" -brew "python@3.9" -brew "python@3.12" -brew "python@3.13" # Databases -brew "mongodb/brew/mongodb-community" -brew "mongodb/brew/mongodb-database-tools" brew "mysql-client" brew "pgsync" brew "planetscale/tap/pscale" brew "postgresql@17" -brew "supabase/tap/supabase" # --------------------------------------------------------------------------------------------------------------------- # GUI applications (cask) @@ -86,30 +71,18 @@ cask "1password" cask "1password-cli" cask "android-platform-tools" cask "android-studio" -cask "anydesk" cask "appcleaner" -cask "azure-data-studio" cask "balenaetcher" -cask "cap" cask "claude-code" -cask "cursor" -cask "cyberduck" -cask "discord" -cask "excalidrawz" cask "expo-orbit" cask "ghostty" -cask "gitbutler" -cask "google-chrome" -cask "grandperspective" -cask "handbrake-app" -cask "iina" cask "imageoptim" cask "keka" +cask "claude" +cask "wispr-flow" cask "kekaexternalhelper" cask "lm-studio" -cask "logi-options+" cask "macs-fan-control" -cask "microsoft-auto-update" cask "microsoft-teams" cask "ngrok" cask "onyx" @@ -117,15 +90,11 @@ cask "openrocket" cask "orbstack" cask "postman" cask "rectangle" -cask "rustdesk" cask "shottr" -cask "sigmaos" cask "sketch" -cask "superhuman" cask "tableplus" cask "tailscale-app" cask "utm" -cask "visual-studio-code" cask "wakatime" cask "whisky" cask "zed" @@ -137,18 +106,13 @@ cask "zulu@17" # Requires being signed into the App Store before `brew bundle` runs. # --------------------------------------------------------------------------------------------------------------------- mas "1Password for Safari", id: 1569813296 -mas "AB Bounce", id: 6462195757 mas "Albums", id: 1469948986 mas "Amphetamine", id: 937984704 mas "Apple Configurator", id: 1037126344 mas "Broadcasts", id: 1469995354 mas "Compressor", id: 424390742 -mas "Craft", id: 1487937127 mas "Crouton", id: 1461650987 mas "Developer", id: 640199958 -mas "Diffusers", id: 1666309574 -mas "Dona", id: 6748265175 -mas "Dynamic Wallpaper Maker", id: 1453846328 mas "Final Cut Pro", id: 424389933 mas "Flighty", id: 1358823008 mas "GarageBand", id: 682658836 @@ -162,13 +126,8 @@ mas "Microsoft Excel", id: 462058435 mas "Microsoft Outlook", id: 985367838 mas "Microsoft Word", id: 462054704 mas "Motion", id: 434290957 -mas "MusicHarbor", id: 1440405750 -mas "Night Sky", id: 475772902 -mas "Numbers", id: 409203825 mas "OneDrive", id: 823766827 -mas "Pages", id: 409201541 mas "Photomator", id: 1444636541 -mas "PowerWash Simulator", id: 6477445344 mas "Reeder", id: 6475002485 mas "Refined GitHub", id: 1519867270 mas "Shazam", id: 897118787 @@ -183,25 +142,6 @@ mas "Wipr", id: 1662217862 mas "WireGuard", id: 1451685025 mas "Xcode - 15", id: 497799835 -# --------------------------------------------------------------------------------------------------------------------- -# VS Code / Cursor extensions -# --------------------------------------------------------------------------------------------------------------------- -vscode "biomejs.biome" -vscode "bradlc.vscode-tailwindcss" -vscode "davidanson.vscode-markdownlint" -vscode "expo.vscode-expo-tools" -vscode "github.codespaces" -vscode "github.copilot-chat" -vscode "github.vscode-github-actions" -vscode "mateocerquetella.xcode-12-theme" -vscode "ms-azuretools.vscode-containers" -vscode "ms-vscode-remote.remote-containers" -vscode "pdconsec.vscode-print" -vscode "redhat.vscode-yaml" -vscode "solomonkinard.git-blame" -vscode "wakatime.vscode-wakatime" -vscode "yoavbls.pretty-ts-errors" - # --------------------------------------------------------------------------------------------------------------------- # Cargo crates (installed via cargo-bundle plugin) # --------------------------------------------------------------------------------------------------------------------- From 37ce14d0c253a5a60cac58ef64745340197a4ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 18:46:31 +0000 Subject: [PATCH 3/7] macos: only apply iTerm SecureKeyboardEntry when iTerm is installed defaults -app errors out (and with set -e aborts the whole script) when the target app doesn't exist. Guard the iTerm line with a directory check so a Ghostty-only setup doesn't bail out mid-defaults. --- macos/defaults.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/macos/defaults.sh b/macos/defaults.sh index 9784ccc..8683322 100755 --- a/macos/defaults.sh +++ b/macos/defaults.sh @@ -49,9 +49,11 @@ elif ! sudo grep -q 'pam_tid.so' /etc/pam.d/sudo; then sudo gsed -i '2iauth sufficient pam_tid.so' /etc/pam.d/sudo fi -# Secure keyboard entry in Terminal-likes +# Secure keyboard entry in Terminal-likes (only for apps that are installed) defaults write -app Terminal SecureKeyboardEntry -bool true -defaults write -app iTerm SecureKeyboardEntry -bool true +if [[ -d "/Applications/iTerm.app" ]]; then + defaults write -app iTerm SecureKeyboardEntry -bool true +fi # Require an administrator password to access system-wide preferences # https://www.tenable.com/audits/CIS_Apple_macOS_11_v2.0.0_L1 From e608f0b8f759090b9ed466cea5ded1483683b667 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 18:55:29 +0000 Subject: [PATCH 4/7] apps: stop launching Terminal during update The 'tell application "Terminal" to set font ...' AppleScript calls launch Terminal.app to apply the change, which surfaces as a random Terminal window every time 'dotfiles update' runs. Drop them; Ghostty is the primary terminal and Terminal.app's defaults still get the non-osascript writes. --- macos/apps.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/apps.sh b/macos/apps.sh index 54ec6e8..f2eca31 100755 --- a/macos/apps.sh +++ b/macos/apps.sh @@ -57,12 +57,12 @@ defaults write com.apple.Safari InstallExtensionUpdatesAutomatically -bool true # --------------------------------------------------------------------------------------------------------------------- # Terminal.app +# Configures defaults *without* launching Terminal (AppleScript would). +# Ghostty is the primary terminal -- configure that separately if needed. # --------------------------------------------------------------------------------------------------------------------- defaults write com.apple.Terminal "Default Window Settings" -string Basic defaults write com.apple.Terminal "Startup Window Settings" -string Basic defaults write com.apple.terminal StringEncodings -array 4 -osascript -e 'tell application "Terminal" to set font name of settings set "Basic" to "MesloLGLNerdFontComplete-Regular"' >/dev/null 2>&1 || true -osascript -e 'tell application "Terminal" to set font size of settings set "Basic" to 18' >/dev/null 2>&1 || true # Required so zsh completion doesn't complain about insecure directories if command -v brew >/dev/null; then From b3649cbcfc87828d6bef5a837bff62fbfde3c4a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 19:08:04 +0000 Subject: [PATCH 5/7] eod: table layout, behind detection, --apply, --fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Compact table: one row per repo with status (●N dirty, ↑N ahead, ↓N behind, no-upstream). Only repos that need attention are shown. - --fetch parallelises 'git fetch' (capped at 10 concurrent) so behind counts reflect the actual remote state. Default mode stays fast and uses last-known state. - --apply walks repos needing attention; the action menu is built from the repo's state -- pull when behind only, push when ahead only, pull --rebase when diverged, stash+rebase+pop when behind+dirty, plus always-available 'shell here', 'view diff', 'view log', skip, quit. Implies --fetch so decisions are based on current state. - Use 'git symbolic-ref --short HEAD' instead of rev-parse --abbrev-ref so unborn-HEAD repos report their branch correctly instead of injecting a '?' into the row. - Make the worktree-skipping explicit in the header comment. Sort the repo list after collecting (drops the GNU-only 'sort -z' dependency). - DIR can be passed as a positional arg; defaults to $DEVELOPER_DIR or ~/Developer. --- bin/eod | 302 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 241 insertions(+), 61 deletions(-) diff --git a/bin/eod b/bin/eod index b297318..ef7c017 100755 --- a/bin/eod +++ b/bin/eod @@ -1,102 +1,282 @@ #!/usr/bin/env bash # eod -- "end of day" git report. # -# Walks every git repository under $DEVELOPER_DIR (default: ~/Developer) and -# reports anything that isn't yet committed or pushed, so nothing gets left -# behind at the end of the day. +# Walks every git repository under DIRECTORY (default: $DEVELOPER_DIR or +# ~/Developer) and reports anything not yet committed, pushed or pulled. +# Worktree leaves and submodules are skipped (their .git is a file, not a dir). # # Usage: -# eod # scan ~/Developer -# eod ~/code # scan a different directory -# DEVELOPER_DIR=~/code eod # same, via env var +# eod [DIR] Show a compact table of repos needing attention. +# eod --fetch [DIR] Fetch each repo first so behind counts are current. +# eod --apply [DIR] Walk through each repo and choose what to do. +# eod -h | --help Show this help. +# +# Notes: +# - Behind counts in the default view reflect the last `git fetch`. Use +# --fetch (or --apply, which implies --fetch) to refresh first. set -euo pipefail -ROOT="${1:-${DEVELOPER_DIR:-$HOME/Developer}}" +# --------------------------------------------------------------------------- +# Args +# --------------------------------------------------------------------------- +APPLY=0 +FETCH=0 +ROOT="" + +print_help() { + awk '/^# eod/,/^$/' "$0" | sed -E 's/^# ?//' +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --apply) APPLY=1; FETCH=1 ;; + --fetch) FETCH=1 ;; + -h|--help) print_help; exit 0 ;; + --) shift; ROOT="${1:-}"; break ;; + -*) echo "eod: unknown flag: $1" >&2; exit 2 ;; + *) ROOT="$1" ;; + esac + shift || true +done +ROOT="${ROOT:-${DEVELOPER_DIR:-$HOME/Developer}}" if [[ ! -d "$ROOT" ]]; then - echo "eod: directory not found: $ROOT" >&2 + echo "eod: not a directory: $ROOT" >&2 exit 1 fi +ROOT="$(cd "$ROOT" && pwd)" -# Colours (disabled when stdout isn't a TTY) +# --------------------------------------------------------------------------- +# Colours +# --------------------------------------------------------------------------- if [[ -t 1 ]]; then - BOLD=$'\033[1m'; DIM=$'\033[2m' - YEL=$'\033[33m'; MAG=$'\033[35m'; CYA=$'\033[36m' - GRN=$'\033[32m'; RST=$'\033[0m' + BOLD=$'\e[1m'; DIM=$'\e[2m'; RST=$'\e[0m' + YEL=$'\e[33m'; MAG=$'\e[35m'; CYA=$'\e[36m' + GRN=$'\e[32m'; RED=$'\e[31m' else - BOLD=""; DIM=""; YEL=""; MAG=""; CYA=""; GRN=""; RST="" + BOLD=""; DIM=""; RST=""; YEL=""; MAG=""; CYA=""; GRN=""; RED="" fi -# Collect repos (prune nested .git directories so we don't recurse into submodules) +# --------------------------------------------------------------------------- +# Discover repos +# `.git` as a directory => regular working tree. Worktree leaves have `.git` +# as a file; submodules likewise. `-type d` skips both. +# --------------------------------------------------------------------------- repos=() while IFS= read -r -d '' git_dir; do repos+=("${git_dir%/.git}") -done < <(find "$ROOT" -name .git -type d -prune -print0 | sort -z) +done < <(find "$ROOT" -name .git -type d -prune -print0) if [[ ${#repos[@]} -eq 0 ]]; then - echo "eod: no git repositories found under $ROOT" + echo "eod: no git repos found under $ROOT" exit 0 fi +IFS=$'\n' repos=($(printf '%s\n' "${repos[@]}" | sort)) +unset IFS -dirty=0 -unpushed=0 -no_upstream=0 +# --------------------------------------------------------------------------- +# Optional parallel fetch +# --------------------------------------------------------------------------- +if [[ $FETCH -eq 1 ]]; then + printf "${DIM}Fetching %d repo(s)…${RST}\n" "${#repos[@]}" + running=0 + for repo in "${repos[@]}"; do + (cd "$repo" && git fetch --quiet --all --prune 2>/dev/null || true) & + running=$((running + 1)) + if [[ $running -ge 10 ]]; then + wait -n 2>/dev/null || wait + running=$((running - 1)) + fi + done + wait +fi -printf "${BOLD}End-of-day report${RST} ${DIM}(%s)${RST}\n\n" "$ROOT" +# --------------------------------------------------------------------------- +# Collect state +# --------------------------------------------------------------------------- +names=() # display path relative to ROOT +branches=() +upstreams=() +dirty=() +ahead=() +behind=() +needs=() # 1 if needs attention for repo in "${repos[@]}"; do - if [[ "$repo" == "$HOME/"* ]]; then - display="~/${repo#$HOME/}" + cd "$repo" + + name="${repo#$ROOT/}" + branch="$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo '?')" + upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)" + + d=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') + a=0; b=0 + if [[ -n "$upstream" ]]; then + if read -r b a < <(git rev-list --left-right --count "@{u}...HEAD" 2>/dev/null); then :; else b=0; a=0; fi + fi + + iss=0 + if [[ $d -gt 0 || $a -gt 0 || $b -gt 0 ]]; then + iss=1 + elif [[ -z "$upstream" ]] && git rev-parse HEAD >/dev/null 2>&1; then + iss=1 + fi + + names+=("$name") + branches+=("$branch") + upstreams+=("$upstream") + dirty+=("$d") + ahead+=("$a") + behind+=("$b") + needs+=("$iss") +done + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +format_status() { # d a b upstream + local d="$1" a="$2" b="$3" up="$4" + local parts=() + [[ $d -gt 0 ]] && parts+=("${YEL}●${d}${RST}") + [[ $a -gt 0 ]] && parts+=("${MAG}↑${a}${RST}") + [[ $b -gt 0 ]] && parts+=("${CYA}↓${b}${RST}") + [[ -z "$up" ]] && parts+=("${DIM}no upstream${RST}") + if [[ ${#parts[@]} -eq 0 ]]; then + printf '%b' "${DIM}─${RST}" else - display="$repo" + (IFS=' '; printf '%b' "${parts[*]}") fi - cd "$repo" +} - status="$(git status --porcelain 2>/dev/null || true)" - branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" +attention=0 +for i in "${!names[@]}"; do + [[ ${needs[i]} -eq 1 ]] && attention=$((attention + 1)) +done - repo_has_issue=0 - repo_lines=() +display_root="${ROOT/#$HOME/~}" - # Uncommitted work - if [[ -n "$status" ]]; then - file_count=$(printf '%s\n' "$status" | wc -l | tr -d ' ') - repo_lines+=(" ${YEL}● uncommitted${RST} ${file_count} file(s)") - dirty=$((dirty + 1)) - repo_has_issue=1 +# --------------------------------------------------------------------------- +# Default mode: table +# --------------------------------------------------------------------------- +if [[ $APPLY -eq 0 ]]; then + printf "${BOLD}end-of-day${RST} %s\n" "$display_root" + + if [[ $attention -eq 0 ]]; then + printf "${GRN}All %d repos caught up.${RST} Have a good evening.\n" "${#repos[@]}" + exit 0 fi - # Unpushed commits (only meaningful with an upstream) - upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)" - if [[ -n "$upstream" ]]; then - ahead=$(git rev-list --count "@{u}..HEAD" 2>/dev/null || echo 0) - if [[ "$ahead" -gt 0 ]]; then - repo_lines+=(" ${MAG}↑ unpushed${RST} ${ahead} commit(s) on ${branch} (-> ${upstream})") - unpushed=$((unpushed + 1)) - repo_has_issue=1 - fi + # Column widths over repos that will actually be printed + max_name=4 + max_branch=6 + for i in "${!names[@]}"; do + [[ ${needs[i]} -eq 0 ]] && continue + [[ ${#names[i]} -gt $max_name ]] && max_name=${#names[i]} + [[ ${#branches[i]} -gt $max_branch ]] && max_branch=${#branches[i]} + done + + echo + for i in "${!names[@]}"; do + [[ ${needs[i]} -eq 0 ]] && continue + status=$(format_status "${dirty[i]}" "${ahead[i]}" "${behind[i]}" "${upstreams[i]}") + printf " %-${max_name}s ${DIM}%-${max_branch}s${RST} %b\n" \ + "${names[i]}" "${branches[i]}" "$status" + done + + printf "\n${DIM}%d of %d need attention.${RST}" "$attention" "${#repos[@]}" + if [[ $FETCH -eq 0 ]]; then + printf " ${DIM}Run \`eod --fetch\` to refresh remote state, or \`eod --apply\` to act.${RST}\n" else - # No upstream: only complain if there are commits beyond the initial state - if git rev-parse HEAD >/dev/null 2>&1 && [[ "$(git rev-list --count HEAD)" -gt 0 ]]; then - repo_lines+=(" ${CYA}↯ no upstream${RST} ${branch} has no remote tracking branch") - no_upstream=$((no_upstream + 1)) - repo_has_issue=1 - fi + printf " ${DIM}Run \`eod --apply\` to act.${RST}\n" fi + exit 0 +fi + +# --------------------------------------------------------------------------- +# --apply mode: walk through each repo with issues +# --------------------------------------------------------------------------- +if [[ $attention -eq 0 ]]; then + printf "${GRN}All %d repos caught up. Nothing to do.${RST}\n" "${#repos[@]}" + exit 0 +fi - if [[ $repo_has_issue -eq 1 ]]; then - printf "${BOLD}%s${RST}\n" "$display" - printf '%s\n' "${repo_lines[@]}" - echo +current=0 +for i in "${!names[@]}"; do + [[ ${needs[i]} -eq 0 ]] && continue + current=$((current + 1)) + + repo="${repos[i]}" + name="${names[i]}" + branch="${branches[i]}" + upstream="${upstreams[i]}" + d=${dirty[i]}; a=${ahead[i]}; b=${behind[i]} + + cd "$repo" + + printf "\n${BOLD}[%d/%d] %s${RST} ${DIM}(%s)${RST}\n" \ + "$current" "$attention" "$name" "$branch" + printf " %b\n\n" "$(format_status "$d" "$a" "$b" "$upstream")" + + # Build state-appropriate menu + options=() + if [[ -z "$upstream" ]]; then + : # no remote actions when there's no upstream + elif [[ $b -gt 0 && $a -eq 0 && $d -eq 0 ]]; then + options+=("u|pull (fast-forward)") + elif [[ $b -gt 0 && $d -gt 0 ]]; then + options+=("t|stash, pull --rebase, pop") + elif [[ $a -gt 0 && $b -gt 0 ]]; then + options+=("r|pull --rebase (pushes can follow after rebase succeeds)") + elif [[ $a -gt 0 && $d -eq 0 ]]; then + options+=("p|push") + elif [[ $a -gt 0 && $d -gt 0 ]]; then + options+=("p|push (won't include uncommitted changes)") fi + + # Always-available actions + [[ $d -gt 0 ]] && options+=("v|view diff") + [[ $a -gt 0 ]] && options+=("l|view unpushed log") + options+=("s|open shell here") + options+=("k|skip") + options+=("q|quit") + + for opt in "${options[@]}"; do + key="${opt%%|*}"; label="${opt#*|}" + printf " ${BOLD}[%s]${RST} %s\n" "$key" "$label" + done + + printf "\n> " + read -r -n 1 choice || true + echo + + case "$choice" in + u) git pull --ff-only || printf "${RED}pull failed${RST}\n" ;; + p) git push || printf "${RED}push failed${RST}\n" ;; + r) git pull --rebase || printf "${RED}rebase failed; resolve manually${RST}\n" ;; + t) + stash="eod-$(date +%Y%m%d-%H%M%S)" + if git stash push -u -m "$stash" >/dev/null; then + if git pull --rebase; then + git stash pop || printf "${RED}stash pop conflicted; resolve in %s (stash kept)${RST}\n" "$name" + else + printf "${RED}rebase failed; your changes are stashed as '%s'${RST}\n" "$stash" + fi + else + printf "${DIM}nothing to stash; pulling anyway…${RST}\n" + git pull --rebase || printf "${RED}rebase failed${RST}\n" + fi + ;; + v) git diff || true ;; + l) git --no-pager log "@{u}..HEAD" --oneline ;; + s) + printf "${DIM}-- shell in %s; exit to continue --${RST}\n" "$name" + "${SHELL:-/bin/zsh}" -i || true + ;; + k|"") printf "${DIM}skipped${RST}\n" ;; + q) printf "${DIM}stopped at %s${RST}\n" "$name"; exit 0 ;; + *) printf "${DIM}'%s' is not a choice; skipped${RST}\n" "$choice" ;; + esac done -clean=$((${#repos[@]} - dirty - unpushed - no_upstream)) -printf "${DIM}Scanned %d repo(s):${RST}\n" "${#repos[@]}" -printf " ${YEL}%d${RST} with uncommitted changes\n" "$dirty" -printf " ${MAG}%d${RST} with unpushed commits\n" "$unpushed" -printf " ${CYA}%d${RST} on a branch with no upstream\n" "$no_upstream" -if [[ $dirty -eq 0 && $unpushed -eq 0 && $no_upstream -eq 0 ]]; then - printf "${GRN}All caught up. Have a good evening.${RST}\n" -fi +printf "\n${GRN}Done.${RST} Have a good evening.\n" From aa3face41c0b2fe1daf93739ebf37b8ae5b631f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 19:18:07 +0000 Subject: [PATCH 6/7] eod: prettier table, default fetch, progress spinner, prune build dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prune common build/dependency directories so their checked-out git repos (e.g. SwiftPM's .build/checkouts/*, node_modules/*) don't pollute the report. Defaults cover .build, node_modules, vendor, Pods, DerivedData, .terraform, .gradle, target, .venv, venv, __pycache__, .next, .nuxt, .svelte-kit, .cache. Override via the EOD_SKIP_DIRS env var. - Fetch by default; add --no-fetch to opt out. --apply no longer needs to imply --fetch since fetch is on. - Background-parallel fetch with a live spinner ("⠋ Fetching… N/M") so the previously-silent hang has feedback. Status line is cleared before normal output. - Real table layout: dimmed REPO/BRANCH/STATUS headers with ─ rules, computed column widths, middle-truncation for over-wide paths and branches. ⊘ instead of literal "no upstream" for compactness. - Prefer en_US.UTF-8 if the locale is available so ${#string} and ─ rendering both work in characters rather than bytes. - Fix a set-e exit: clear_status returned 1 when stdout wasn't a TTY, which aborted the script under `set -e`. Both status helpers now explicitly return 0. --- bin/eod | 255 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 195 insertions(+), 60 deletions(-) diff --git a/bin/eod b/bin/eod index ef7c017..584102e 100755 --- a/bin/eod +++ b/bin/eod @@ -4,24 +4,32 @@ # Walks every git repository under DIRECTORY (default: $DEVELOPER_DIR or # ~/Developer) and reports anything not yet committed, pushed or pulled. # Worktree leaves and submodules are skipped (their .git is a file, not a dir). +# Build/dependency directories listed in EOD_SKIP_DIRS are pruned entirely. # # Usage: # eod [DIR] Show a compact table of repos needing attention. -# eod --fetch [DIR] Fetch each repo first so behind counts are current. +# eod --no-fetch [DIR] Skip `git fetch` (use last-known remote state). # eod --apply [DIR] Walk through each repo and choose what to do. # eod -h | --help Show this help. # -# Notes: -# - Behind counts in the default view reflect the last `git fetch`. Use -# --fetch (or --apply, which implies --fetch) to refresh first. +# Environment: +# EOD_SKIP_DIRS Space-separated directory names to prune. Defaults to +# common build/dependency caches that contain checked-out +# git repos (Swift, Node, Go vendor, Xcode, Rust, etc.). set -euo pipefail +# Prefer a UTF-8 locale so multi-byte glyphs (●, ↑, ─, …) render and +# ${#string} counts characters, not bytes. macOS ships en_US.UTF-8. +if [[ -z "${LC_ALL:-}" ]] && locale -a 2>/dev/null | grep -qi '^en_US\.UTF-8$'; then + export LC_ALL=en_US.UTF-8 +fi + # --------------------------------------------------------------------------- # Args # --------------------------------------------------------------------------- APPLY=0 -FETCH=0 +FETCH=1 ROOT="" print_help() { @@ -30,12 +38,13 @@ print_help() { while [[ $# -gt 0 ]]; do case "$1" in - --apply) APPLY=1; FETCH=1 ;; - --fetch) FETCH=1 ;; - -h|--help) print_help; exit 0 ;; - --) shift; ROOT="${1:-}"; break ;; - -*) echo "eod: unknown flag: $1" >&2; exit 2 ;; - *) ROOT="$1" ;; + --apply) APPLY=1 ;; + --fetch) FETCH=1 ;; + --no-fetch) FETCH=0 ;; + -h|--help) print_help; exit 0 ;; + --) shift; ROOT="${1:-}"; break ;; + -*) echo "eod: unknown flag: $1" >&2; exit 2 ;; + *) ROOT="$1" ;; esac shift || true done @@ -47,26 +56,106 @@ if [[ ! -d "$ROOT" ]]; then fi ROOT="$(cd "$ROOT" && pwd)" +# Directories to prune. Override via EOD_SKIP_DIRS env var. +DEFAULT_SKIP_DIRS=( + .build # SwiftPM + node_modules # Node + vendor # Go modules / PHP / Ruby + Pods # CocoaPods + DerivedData # Xcode + .terraform # Terraform + .gradle # Gradle + target # Rust / Java + .venv venv # Python virtualenvs + __pycache__ + .next .nuxt .svelte-kit # JS frameworks + .cache +) +IFS=' ' read -r -a SKIP_DIRS <<< "${EOD_SKIP_DIRS:-${DEFAULT_SKIP_DIRS[*]}}" + # --------------------------------------------------------------------------- -# Colours +# Colours / UI # --------------------------------------------------------------------------- if [[ -t 1 ]]; then BOLD=$'\e[1m'; DIM=$'\e[2m'; RST=$'\e[0m' YEL=$'\e[33m'; MAG=$'\e[35m'; CYA=$'\e[36m' GRN=$'\e[32m'; RED=$'\e[31m' + CLR_LINE=$'\r\e[K' else - BOLD=""; DIM=""; RST=""; YEL=""; MAG=""; CYA=""; GRN=""; RED="" + BOLD=""; DIM=""; RST=""; YEL=""; MAG=""; CYA=""; GRN=""; RED=""; CLR_LINE="" fi +SPIN=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) + +term_width() { + local cols + cols="${COLUMNS:-$(tput cols 2>/dev/null || echo 80)}" + [[ "$cols" =~ ^[0-9]+$ ]] && echo "$cols" || echo 80 +} + +# Replacement-character-aware middle truncation. Assumes 1-cell-per-char glyphs +# (true for the user-facing paths and branches we display). +truncate_middle() { + local s="$1" max="$2" + if [[ ${#s} -le $max ]]; then + printf '%s' "$s" + else + local keep=$((max - 1)) + local head=$(( keep / 2 )) + local tail=$(( keep - head )) + printf '%s…%s' "${s:0:$head}" "${s: -$tail}" + fi +} + +# Print and clear a "live" status line on the TTY. These must always return +# 0 even when there's no TTY -- they're called under `set -e`. +status() { + if [[ -n "$CLR_LINE" ]]; then + printf '%s%s' "$CLR_LINE" "$1" + fi + return 0 +} + +clear_status() { + if [[ -n "$CLR_LINE" ]]; then + printf '%s' "$CLR_LINE" + fi + return 0 +} + # --------------------------------------------------------------------------- # Discover repos -# `.git` as a directory => regular working tree. Worktree leaves have `.git` -# as a file; submodules likewise. `-type d` skips both. +# `.git` as a directory => regular working tree. Worktree leaves and +# submodules have `.git` as a file; `-type d` skips both. # --------------------------------------------------------------------------- +status "${SPIN[0]} Finding repos in $(truncate_middle "$ROOT" 60)…" + +# Build a find expression that prunes the skip dirs: +# find ROOT \( -type d \( -name A -o -name B ... \) -prune \) -o \ +# \( -name .git -type d -prune -print0 \) +find_expr=( "$ROOT" ) +if [[ ${#SKIP_DIRS[@]} -gt 0 ]]; then + find_expr+=( '(' '-type' 'd' '(' ) + first=1 + for d in "${SKIP_DIRS[@]}"; do + [[ -z "$d" ]] && continue + if [[ $first -eq 1 ]]; then + find_expr+=( '-name' "$d" ) + first=0 + else + find_expr+=( '-o' '-name' "$d" ) + fi + done + find_expr+=( ')' '-prune' ')' '-o' ) +fi +find_expr+=( '(' '-name' '.git' '-type' 'd' '-prune' '-print0' ')' ) + repos=() while IFS= read -r -d '' git_dir; do repos+=("${git_dir%/.git}") -done < <(find "$ROOT" -name .git -type d -prune -print0) +done < <(find "${find_expr[@]}") + +clear_status if [[ ${#repos[@]} -eq 0 ]]; then echo "eod: no git repos found under $ROOT" @@ -76,36 +165,60 @@ IFS=$'\n' repos=($(printf '%s\n' "${repos[@]}" | sort)) unset IFS # --------------------------------------------------------------------------- -# Optional parallel fetch +# Parallel fetch with live spinner # --------------------------------------------------------------------------- if [[ $FETCH -eq 1 ]]; then - printf "${DIM}Fetching %d repo(s)…${RST}\n" "${#repos[@]}" - running=0 - for repo in "${repos[@]}"; do - (cd "$repo" && git fetch --quiet --all --prune 2>/dev/null || true) & - running=$((running + 1)) - if [[ $running -ge 10 ]]; then - wait -n 2>/dev/null || wait - running=$((running - 1)) - fi + status_dir=$(mktemp -d -t eod.XXXXXX) + trap 'rm -rf "$status_dir"' EXIT + + total=${#repos[@]} + + ( + running=0 idx=0 + for repo in "${repos[@]}"; do + idx=$((idx + 1)) + ( + cd "$repo" && git fetch --quiet --all --prune 2>/dev/null || true + touch "$status_dir/$idx" + ) & + running=$((running + 1)) + if [[ $running -ge 10 ]]; then + wait -n 2>/dev/null || wait + running=$((running - 1)) + fi + done + wait + touch "$status_dir/.done" + ) & + + spin_i=0 + while [[ ! -e "$status_dir/.done" ]]; do + done_n=$(find "$status_dir" -maxdepth 1 -type f \! -name '.done' 2>/dev/null | wc -l | tr -d ' ') + status "${SPIN[$spin_i]} Fetching… ${DIM}$done_n/$total${RST}" + spin_i=$(( (spin_i + 1) % ${#SPIN[@]} )) + sleep 0.08 done wait + clear_status fi # --------------------------------------------------------------------------- # Collect state # --------------------------------------------------------------------------- -names=() # display path relative to ROOT +names=() branches=() upstreams=() dirty=() ahead=() behind=() -needs=() # 1 if needs attention +needs=() +idx=0 for repo in "${repos[@]}"; do - cd "$repo" + idx=$((idx + 1)) + status "${SPIN[$((idx % ${#SPIN[@]}))]} Analysing… ${DIM}$idx/${#repos[@]}${RST}" + cd "$repo" name="${repo#$ROOT/}" branch="$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo '?')" upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)" @@ -113,7 +226,9 @@ for repo in "${repos[@]}"; do d=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') a=0; b=0 if [[ -n "$upstream" ]]; then - if read -r b a < <(git rev-list --left-right --count "@{u}...HEAD" 2>/dev/null); then :; else b=0; a=0; fi + if ! read -r b a < <(git rev-list --left-right --count "@{u}...HEAD" 2>/dev/null); then + b=0; a=0 + fi fi iss=0 @@ -131,22 +246,20 @@ for repo in "${repos[@]}"; do behind+=("$b") needs+=("$iss") done +clear_status # --------------------------------------------------------------------------- -# Helpers +# Status formatter # --------------------------------------------------------------------------- format_status() { # d a b upstream local d="$1" a="$2" b="$3" up="$4" - local parts=() - [[ $d -gt 0 ]] && parts+=("${YEL}●${d}${RST}") - [[ $a -gt 0 ]] && parts+=("${MAG}↑${a}${RST}") - [[ $b -gt 0 ]] && parts+=("${CYA}↓${b}${RST}") - [[ -z "$up" ]] && parts+=("${DIM}no upstream${RST}") - if [[ ${#parts[@]} -eq 0 ]]; then - printf '%b' "${DIM}─${RST}" - else - (IFS=' '; printf '%b' "${parts[*]}") - fi + local out="" + [[ $d -gt 0 ]] && out+="${YEL}●${d}${RST} " + [[ $a -gt 0 ]] && out+="${MAG}↑${a}${RST} " + [[ $b -gt 0 ]] && out+="${CYA}↓${b}${RST} " + [[ -z "$up" ]] && out+="${DIM}⊘ no upstream${RST} " + [[ -z "$out" ]] && out="${DIM}─${RST}" + printf '%s' "${out% }" } attention=0 @@ -160,41 +273,65 @@ display_root="${ROOT/#$HOME/~}" # Default mode: table # --------------------------------------------------------------------------- if [[ $APPLY -eq 0 ]]; then - printf "${BOLD}end-of-day${RST} %s\n" "$display_root" - if [[ $attention -eq 0 ]]; then + printf "${BOLD}end-of-day${RST} ${DIM}%s${RST}\n" "$display_root" printf "${GRN}All %d repos caught up.${RST} Have a good evening.\n" "${#repos[@]}" exit 0 fi - # Column widths over repos that will actually be printed - max_name=4 - max_branch=6 + # Reserve a sensible chunk of the terminal for the path column. + tw=$(term_width) + max_path_cap=$(( tw - 32 )) # leave room for branch + status + padding + (( max_path_cap < 24 )) && max_path_cap=24 + (( max_path_cap > 80 )) && max_path_cap=80 + max_branch_cap=24 + + # Compute actual column widths from the (possibly truncated) data. + name_w=4 + branch_w=6 + display_names=() + display_branches=() for i in "${!names[@]}"; do [[ ${needs[i]} -eq 0 ]] && continue - [[ ${#names[i]} -gt $max_name ]] && max_name=${#names[i]} - [[ ${#branches[i]} -gt $max_branch ]] && max_branch=${#branches[i]} + n=$(truncate_middle "${names[i]}" "$max_path_cap") + b=$(truncate_middle "${branches[i]}" "$max_branch_cap") + display_names+=("$n") + display_branches+=("$b") + (( ${#n} > name_w )) && name_w=${#n} + (( ${#b} > branch_w )) && branch_w=${#b} done - echo + printf "${BOLD}end-of-day${RST} ${DIM}%s · %d of %d need attention${RST}\n\n" \ + "$display_root" "$attention" "${#repos[@]}" + + # Headers + rule() { # repeat ─ N times + local n="$1" s="" + while (( n-- > 0 )); do s+="─"; done + printf '%s' "$s" + } + printf " ${DIM}%-${name_w}s %-${branch_w}s %s${RST}\n" "REPO" "BRANCH" "STATUS" + printf " ${DIM}%s %s %s${RST}\n" "$(rule "$name_w")" "$(rule "$branch_w")" "──────" + + j=0 for i in "${!names[@]}"; do [[ ${needs[i]} -eq 0 ]] && continue - status=$(format_status "${dirty[i]}" "${ahead[i]}" "${behind[i]}" "${upstreams[i]}") - printf " %-${max_name}s ${DIM}%-${max_branch}s${RST} %b\n" \ - "${names[i]}" "${branches[i]}" "$status" + status_str=$(format_status "${dirty[i]}" "${ahead[i]}" "${behind[i]}" "${upstreams[i]}") + printf " %-${name_w}s ${DIM}%-${branch_w}s${RST} %b\n" \ + "${display_names[$j]}" "${display_branches[$j]}" "$status_str" + j=$((j + 1)) done - printf "\n${DIM}%d of %d need attention.${RST}" "$attention" "${#repos[@]}" if [[ $FETCH -eq 0 ]]; then - printf " ${DIM}Run \`eod --fetch\` to refresh remote state, or \`eod --apply\` to act.${RST}\n" + printf "\n${DIM}Last-known remote state. Drop \`--no-fetch\` to refresh, or \`eod --apply\` to act.${RST}\n" else - printf " ${DIM}Run \`eod --apply\` to act.${RST}\n" + printf "\n${DIM}\`eod --apply\` to act on these.${RST}\n" fi exit 0 fi # --------------------------------------------------------------------------- -# --apply mode: walk through each repo with issues +# --apply mode # --------------------------------------------------------------------------- if [[ $attention -eq 0 ]]; then printf "${GRN}All %d repos caught up. Nothing to do.${RST}\n" "${#repos[@]}" @@ -218,23 +355,21 @@ for i in "${!names[@]}"; do "$current" "$attention" "$name" "$branch" printf " %b\n\n" "$(format_status "$d" "$a" "$b" "$upstream")" - # Build state-appropriate menu options=() if [[ -z "$upstream" ]]; then - : # no remote actions when there's no upstream + : elif [[ $b -gt 0 && $a -eq 0 && $d -eq 0 ]]; then options+=("u|pull (fast-forward)") elif [[ $b -gt 0 && $d -gt 0 ]]; then options+=("t|stash, pull --rebase, pop") elif [[ $a -gt 0 && $b -gt 0 ]]; then - options+=("r|pull --rebase (pushes can follow after rebase succeeds)") + options+=("r|pull --rebase") elif [[ $a -gt 0 && $d -eq 0 ]]; then options+=("p|push") elif [[ $a -gt 0 && $d -gt 0 ]]; then options+=("p|push (won't include uncommitted changes)") fi - # Always-available actions [[ $d -gt 0 ]] && options+=("v|view diff") [[ $a -gt 0 ]] && options+=("l|view unpushed log") options+=("s|open shell here") From c262b5d3b3ad381e0ff2b48e9853ff1715aecd9b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 19:27:55 +0000 Subject: [PATCH 7/7] ci: bare-minimum install smoke test on macOS Runs on push to main, on PRs that touch Brewfile/Makefile/scripts, and on manual dispatch. Uses macos-15 runners (Apple Silicon). What it covers: - brew bundle against a generated Brewfile.ci containing only tap and brew lines -- mas needs an Apple ID, casks are slow and irrelevant for verifying the CLI surface. - make stow with conflicting hosted-runner dotfiles removed first, so the symlink layout actually gets created. - Symlink existence check for the dotfiles we expect to manage. - brew --version + brew list smoke. - node + pnpm round-trip (init, install ms, require it from node). - eod against three fake repos covering clean/dirty/ahead states. - make doctor. What it deliberately skips: cask installs, mas, the macOS defaults and dock/apps modules (no apply target on a CI runner). --- .github/workflows/install.yml | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/install.yml diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 0000000..d40d1da --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,111 @@ +name: install + +# Smoke-test that a fresh Mac install still works. +# Runs the brew-only subset of the Brewfile (mas needs an Apple ID, casks +# are slow and irrelevant to CI), stows the dotfiles, then sanity-checks +# that the tooling we rely on (brew, pnpm, eod) is functional. + +on: + push: + branches: [main] + pull_request: + paths: + - Brewfile + - Makefile + - bin/** + - home/** + - macos/** + - scripts/** + - .github/workflows/** + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + install: + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Show environment + run: | + sw_vers + uname -m + brew --version + which brew + + - name: Brews-only Brewfile (skip mas + cask + vscode + cargo) + run: | + grep -E '^(tap|brew) ' Brewfile > Brewfile.ci + echo "--- Brewfile.ci ---" + cat Brewfile.ci + + - name: brew bundle + run: brew bundle --no-lock --file=Brewfile.ci + + - name: make stow (clear conflicts first) + run: | + # The hosted runner ships some default dotfiles; remove the ones + # we manage so stow doesn't refuse to link. + rm -f ~/.zshrc ~/.gitconfig ~/.gitignore_global ~/.p10k.zsh + rm -f ~/.ssh/config + rm -rf ~/.config/nvim + make stow + + - name: Verify symlinks + run: | + set -x + test -L ~/.zshrc + test -L ~/.gitconfig + test -L ~/.p10k.zsh + test -L ~/.gitignore_global + test -L ~/.ssh/config + test -L ~/.config/nvim/init.lua + + - name: brew works + run: | + brew --version + brew list --formula | head -20 + + - name: node + pnpm work + run: | + node --version + pnpm --version + # Round-trip: pnpm can init a project and install a tiny dep. + mkdir -p /tmp/pnpm-test && cd /tmp/pnpm-test + pnpm init >/dev/null + pnpm add --silent ms >/dev/null + test -f node_modules/ms/package.json + node -e "console.log(require('ms')('2 days'))" + + - name: eod smoke test + run: | + set -x + export DEVELOPER_DIR=/tmp/test-repos + mkdir -p "$DEVELOPER_DIR"/{clean,dirty,ahead} + + # clean repo + git -C "$DEVELOPER_DIR/clean" init -q + git -C "$DEVELOPER_DIR/clean" -c user.email=t@t -c user.name=t commit --allow-empty -q -m initial + + # dirty repo + git -C "$DEVELOPER_DIR/dirty" init -q + touch "$DEVELOPER_DIR/dirty/foo.txt" + + # ahead repo (fake upstream) + git -C "$DEVELOPER_DIR/ahead" init -q + git -C "$DEVELOPER_DIR/ahead" -c user.email=t@t -c user.name=t commit --allow-empty -q -m initial + git -C "$DEVELOPER_DIR/ahead" update-ref refs/remotes/origin/main HEAD + git -C "$DEVELOPER_DIR/ahead" remote add origin /tmp/fake.git + git -C "$DEVELOPER_DIR/ahead" symbolic-ref HEAD refs/heads/main + git -C "$DEVELOPER_DIR/ahead" -c user.email=t@t -c user.name=t commit --allow-empty -q -m local + git -C "$DEVELOPER_DIR/ahead" branch --set-upstream-to=origin/main >/dev/null + + ./bin/eod --no-fetch "$DEVELOPER_DIR" + + - name: make doctor + run: make doctor