Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,15 @@ jobs:
sudo install "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/
shellcheck --version

# Derived from what's tracked, so a new script can't ship unlinted.
- name: Run shellcheck
run: shellcheck bootstrap.sh doctor.sh macos/*.sh
run: git ls-files '*.sh' | xargs -r shellcheck

- name: Check zsh syntax
run: |
sudo apt-get update -qq
sudo apt-get install -y zsh
zsh -n zsh/.zshrc
zsh -n zsh/.zprofile
zsh -n zsh/.aliases
git ls-files 'zsh/*' ':!zsh/*.md' | xargs -r -n1 zsh -n

macos:
runs-on: macos-latest
Expand All @@ -50,8 +49,9 @@ jobs:
# Resolves every entry against Homebrew without installing anything.
- name: Validate Brewfiles
run: |
brew bundle list --file=Brewfile
brew bundle list --file=Brewfile.personal
git ls-files 'Brewfile*' | while read -r bf; do
brew bundle list --file="$bf"
done

- name: Lint property lists
run: plutil -lint macos/launch-clipy/Info.plist macos/launch-clipy/document.wflow
run: git ls-files 'macos/*.plist' 'macos/*.wflow' | xargs -r plutil -lint
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Each directory mirrors `$HOME` and is symlinked in by `stow`:
| `Brewfile` | Core CLI tools, Cask apps, and App Store apps — every machine |
| `Brewfile.personal` | Opt-in media, games, and creative apps |
| `doctor.sh` | Read-only drift check — see [Verifying](#verifying) |
| `lib.sh` | Shared package list and machine-tier helpers for both scripts |

---

Expand Down Expand Up @@ -109,7 +110,7 @@ It configures, end to end:
- **Screenshots** — PNG, no shadow, saved to `~/Desktop/Screenshots`
- **Menu bar** — Control Center icon and Now Playing visible
- **Clock** — AM/PM with day of week, no date
- **Security** — Touch ID for `sudo`, application firewall with stealth mode; FileVault status reported but never changed automatically
- **Security** — Touch ID for `sudo`, application firewall with stealth mode; FileVault is never changed automatically (`doctor.sh` reports its status)
- **Third-party apps** — sensible defaults for SizeUp and Clipy
- **Login items** — SizeUp, Mullvad VPN, Amphetamine, Ice, and a Launch Clipy Automator applet, all registered automatically

Expand Down Expand Up @@ -261,7 +262,8 @@ stow --restow --target="$HOME" <package>
If the tool needs installing too, add it to [`Brewfile`](Brewfile) (or
[`Brewfile.personal`](Brewfile.personal)) rather than to `bootstrap.sh` — that
keeps `doctor.sh` able to detect it as missing. Add the package name to
`STOW_PACKAGES` in `bootstrap.sh` and to the loop in `doctor.sh`.
`STOW_PACKAGES` in [`lib.sh`](lib.sh); `bootstrap.sh` and `doctor.sh` both read
it from there.

---

Expand Down
110 changes: 50 additions & 60 deletions bootstrap.sh
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
#!/usr/bin/env bash
# Bootstrap a machine from this repo. Idempotent; safe to re-run.
#
# ./bootstrap.sh interactive
# ./bootstrap.sh --yes assume defaults, never prompt
# ./bootstrap.sh --personal include Brewfile.personal (records the choice)
# ./bootstrap.sh --no-personal core packages only (records the choice)
# ./bootstrap.sh --no-defaults skip macos/defaults.sh
# ./bootstrap.sh --check report drift via doctor.sh and exit

set -euo pipefail

DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MACHINE_FILE="$DOTFILES_DIR/.machine"
# shellcheck source=lib.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"

trap 'echo ">>> bootstrap failed at line $LINENO" >&2' ERR

usage() {
cat <<'EOF'
Bootstrap a machine from this repo. Idempotent; safe to re-run.

./bootstrap.sh interactive
./bootstrap.sh --yes assume defaults, never prompt
./bootstrap.sh --personal include Brewfile.personal (records the choice)
./bootstrap.sh --no-personal core packages only (records the choice)
./bootstrap.sh --no-defaults skip macos/defaults.sh
./bootstrap.sh --check report drift via doctor.sh and exit
EOF
}

# ---- Options ----
ASSUME_YES=0
APPLY_DEFAULTS=1
Expand All @@ -27,44 +33,41 @@ while [[ $# -gt 0 ]]; do
--no-personal) PERSONAL=no ;;
--no-defaults) APPLY_DEFAULTS=0 ;;
--check) exec bash "$DOTFILES_DIR/doctor.sh" ;;
-h|--help) sed -n '2,10p' "${BASH_SOURCE[0]}"; exit 0 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
shift
done

# ---- Platform detection ----
OS="$(uname)"
if [[ "$OS" == "Darwin" ]]; then
PLATFORM="macos"
elif grep -qi microsoft /proc/version 2>/dev/null; then
PLATFORM="wsl"
else
PLATFORM="linux"
fi
# confirm <question> — true if the user says yes. Unattended runs (--yes or no
# tty) never prompt and take the default baked into each call site.
confirm() {
local answer
read -rp ">>> $1 [y/N] " answer
[[ "${answer:-N}" =~ ^[Yy]$ ]]
}

interactive() { (( ! ASSUME_YES )) && [[ -t 0 ]]; }

echo ">>> Detected platform: $PLATFORM"

# ---- Resolve the machine tier ----
# An explicit flag wins and rewrites the record; otherwise reuse a previous
# answer; otherwise ask. Unattended runs default to core-only so they never
# pull down multi-gigabyte personal apps.
if [[ -n "$PERSONAL" ]]; then
echo "PERSONAL=$PERSONAL" > "$MACHINE_FILE"
elif [[ -f "$MACHINE_FILE" ]]; then
# shellcheck source=/dev/null
source "$MACHINE_FILE"
PERSONAL="${PERSONAL:-no}"
elif (( ASSUME_YES )) || [[ ! -t 0 ]]; then
PERSONAL=no
echo "PERSONAL=$PERSONAL" > "$MACHINE_FILE"
else
echo ""
read -rp ">>> Is this a personal machine? Installs media, games, and creative apps. [y/N] " _personal
[[ "${_personal:-N}" =~ ^[Yy]$ ]] && PERSONAL=yes || PERSONAL=no
echo "PERSONAL=$PERSONAL" > "$MACHINE_FILE"
# An explicit flag wins; otherwise reuse a previous answer; otherwise ask.
# Unattended runs default to core-only so they never pull down multi-gigabyte
# personal apps. The resolved answer is always recorded for later runs.
if [[ -z "$PERSONAL" ]]; then
if [[ -f "$MACHINE_FILE" ]]; then
load_machine_tier
elif interactive; then
echo ""
confirm "Is this a personal machine? Installs media, games, and creative apps." &&
PERSONAL=yes || PERSONAL=no
else
PERSONAL=no
fi
fi
echo ">>> Machine tier: $([[ "$PERSONAL" == yes ]] && echo "core + personal" || echo "core only")"
save_machine_tier
echo ">>> Machine tier: $(tier_label)"

# ---- Install dependencies ----
echo ">>> Installing dependencies..."
Expand Down Expand Up @@ -102,10 +105,10 @@ if [[ "$PLATFORM" == "macos" ]]; then

# A missing App Store sign-in makes `mas` entries fail. Warn and carry on
# rather than aborting before anything gets stowed.
brew bundle --file="$DOTFILES_DIR/Brewfile" || deps_failed=1
if [[ "$PERSONAL" == yes ]]; then
brew bundle --file="$DOTFILES_DIR/Brewfile.personal" || deps_failed=1
fi
load_brewfiles
for bf in "${BREWFILES[@]}"; do
brew bundle --file="$bf" || deps_failed=1
done
(( deps_failed )) && echo ">>> Warning: some packages failed to install; continuing." >&2
elif [[ "$PLATFORM" == "wsl" || "$PLATFORM" == "linux" ]]; then
sudo apt-get update -qq
Expand Down Expand Up @@ -139,20 +142,16 @@ echo ">>> Stowing shared packages..."
cd "$DOTFILES_DIR"

# ssh goes first and on its own, so an unrelated conflict elsewhere can't leave
# ~/.ssh deleted between the unfold and the restow.
#
# --no-folding keeps ~/.ssh a real directory containing a symlinked config.
# Folded, ~/.ssh would itself be a symlink into this repo, and every key or
# known_hosts file written there would land in the git working tree.
# ~/.ssh deleted between the unfold and the restow. See lib.sh for why it needs
# SSH_STOW_OPTS.
stow_failed=0
if [[ -L "$HOME/.ssh" ]]; then
echo ">>> Unfolding ~/.ssh (currently a symlink into the repo)..."
stow --delete --target="$HOME" ssh
fi
stow --restow --no-folding --target="$HOME" ssh || stow_failed=1
stow --restow "${SSH_STOW_OPTS[@]}" --target="$HOME" ssh || stow_failed=1
[[ -d "$HOME/.ssh" ]] && chmod 700 "$HOME/.ssh"

STOW_PACKAGES=(zsh git tmux nvim base16 claude)
stow --restow --target="$HOME" "${STOW_PACKAGES[@]}" || stow_failed=1

if (( stow_failed )); then
Expand Down Expand Up @@ -196,25 +195,16 @@ fi
# ---- macOS system defaults ----
applied_defaults=0
if [[ "$PLATFORM" == "macos" ]] && (( APPLY_DEFAULTS )); then
if (( ASSUME_YES )) || [[ ! -t 0 ]]; then
_apply_defaults=y
else
echo ""
read -rp ">>> Apply macOS system defaults (Dock, Appearance, Keyboard, Finder)? [y/N] " _apply_defaults
fi
if [[ "${_apply_defaults:-N}" =~ ^[Yy]$ ]]; then
interactive && echo ""
# Unattended runs apply them; interactive runs ask.
if ! interactive || confirm "Apply macOS system defaults (Dock, Appearance, Keyboard, Finder)?"; then
bash "$DOTFILES_DIR/macos/defaults.sh"
applied_defaults=1
else
echo ">>> Skipped. Run manually: bash $DOTFILES_DIR/macos/defaults.sh"
fi
fi

# ---- Verify ----
if [[ "$(readlink -f "$HOME/.zshrc" 2>/dev/null)" != "$DOTFILES_DIR"/* ]]; then
echo ">>> Warning: ~/.zshrc does not resolve into $DOTFILES_DIR." >&2
fi

# ---- Done ----
echo ""
echo "✓ Bootstrap complete."
Expand Down
49 changes: 21 additions & 28 deletions doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,36 @@

set -uo pipefail # deliberately no -e: every check runs even if one fails

DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MACHINE_FILE="$DOTFILES_DIR/.machine"
# shellcheck source=lib.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh"

drift=0
section() { echo ""; echo "== $1"; }
ok() { echo " ✓ $*"; }
bad() { drift=1; echo " ✗ $*"; }
hint() { echo " → $*"; }
# detail — indent a captured block of command output under its ✗ line.
detail() { while IFS= read -r line; do [[ -n "$line" ]] && echo " $line"; done; }

PERSONAL=no
if [[ -f "$MACHINE_FILE" ]]; then
# shellcheck source=/dev/null
source "$MACHINE_FILE"
PERSONAL="${PERSONAL:-no}"
fi
load_machine_tier

# ---------------------------------------------------------------------------
section "Packages (tier: $([[ "$PERSONAL" == yes ]] && echo "core + personal" || echo "core only"))"
section "Packages (tier: $(tier_label))"
# ---------------------------------------------------------------------------
if ! command -v brew &>/dev/null; then
bad "Homebrew not installed"
hint "./bootstrap.sh"
else
BREWFILES=("$DOTFILES_DIR/Brewfile")
[[ "$PERSONAL" == yes ]] && BREWFILES+=("$DOTFILES_DIR/Brewfile.personal")
load_brewfiles

for bf in "${BREWFILES[@]}"; do
if missing="$(brew bundle check --file="$bf" --verbose 2>&1 | grep '^→' || true)"; then
if [[ -n "$missing" ]]; then
bad "${bf##*/}: missing entries"
while IFS= read -r line; do echo " $line"; done <<<"$missing"
hint "brew bundle install --file=$bf"
else
ok "${bf##*/}: all entries installed"
fi
missing="$(brew bundle check --file="$bf" --verbose 2>&1 | grep '^→' || true)"
if [[ -n "$missing" ]]; then
bad "${bf##*/}: missing entries"
detail <<<"$missing"
hint "brew bundle install --file=$bf"
else
ok "${bf##*/}: all entries installed"
fi
done

Expand All @@ -56,7 +51,7 @@ else
rm -f "$combined"
if [[ -n "$extras" ]]; then
bad "installed but not listed in any in-scope Brewfile"
while IFS= read -r line; do echo " $line"; done <<<"$extras"
detail <<<"$extras"
hint "add them to Brewfile / Brewfile.personal, or uninstall them"
else
ok "no unlisted packages"
Expand Down Expand Up @@ -87,17 +82,17 @@ else
fi
fi

for pkg in zsh git tmux ssh nvim base16 claude; do
for pkg in "${STOW_PACKAGES[@]}" ssh; do
args=(--dir="$DOTFILES_DIR" --no --restow --target="$HOME" "$pkg")
[[ "$pkg" == ssh ]] && args+=(--no-folding)
[[ "$pkg" == ssh ]] && args+=("${SSH_STOW_OPTS[@]}")
# stow always emits a simulation-mode banner under --no; drop it so only
# real conflicts and pending link changes remain.
out="$(stow "${args[@]}" 2>&1 | grep -v '^WARNING: in simulation mode' || true)"
if [[ -z "$out" ]]; then
ok "$pkg"
else
bad "$pkg is not fully stowed"
while IFS= read -r line; do [[ -n "$line" ]] && echo " $line"; done <<<"$out"
detail <<<"$out"
hint "./bootstrap.sh, or stow --adopt --target=\"\$HOME\" $pkg"
fi
done
Expand All @@ -106,7 +101,7 @@ fi
# ---------------------------------------------------------------------------
section "macOS defaults"
# ---------------------------------------------------------------------------
if [[ "$(uname)" != "Darwin" ]]; then
if [[ "$PLATFORM" != "macos" ]]; then
ok "skipped (not macOS)"
else
# Capture first: defaults.sh --check exits 1 on drift, and under pipefail a
Expand All @@ -127,9 +122,7 @@ section "Repo"
sub_status="$(cd "$DOTFILES_DIR" && git submodule status 2>/dev/null)"
if grep -qE '^[+-]' <<<"$sub_status"; then
bad "submodules out of sync"
while IFS= read -r line; do
[[ "$line" =~ ^[+-] ]] && echo " $line"
done <<<"$sub_status"
grep -E '^[+-]' <<<"$sub_status" | detail
hint "git submodule update --init --recursive"
else
ok "submodules at recorded commits"
Expand All @@ -153,7 +146,7 @@ else
hint "chsh -s $zsh_path"
fi

if [[ "$(uname)" == "Darwin" ]]; then
if [[ "$PLATFORM" == "macos" ]]; then
if xcode-select -p &>/dev/null; then
ok "Xcode Command Line Tools at $(xcode-select -p)"
else
Expand Down
Loading
Loading