From df618902687e5d202c87187396347541c0625841 Mon Sep 17 00:00:00 2001 From: Julian Montez Date: Sat, 8 Aug 2026 22:23:08 -0400 Subject: [PATCH] Share definitions via lib.sh, fold Launch Clipy into login items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap.sh and doctor.sh each carried their own copy of the stow package list, the Brewfile tier logic, the .machine read, and the platform check. Nothing kept them in sync: adding a stow package meant editing two lists, and doctor.sh's ssh --no-folding was a bare literal that had to match bootstrap.sh's by hand. Move all of it into lib.sh — DOTFILES_DIR, PLATFORM, STOW_PACKAGES, SSH_STOW_OPTS, and helpers for loading/saving the machine tier and resolving in-scope Brewfiles. The file only defines; sourcing it runs nothing. Launch Clipy was built and registered as a login item by build-launch-clipy.sh, outside LOGIN_ITEM_APPS, so --check never covered it. Make the applet a LOGIN_ITEM_APPS row and have defaults.sh build it just before the login-item pass. Its own existence can't ride on that row — process_login_items skips apps that aren't present, so an unbuilt applet is indistinguishable from an uninstalled one — hence build-launch-clipy.sh --check, exiting 0 built / 1 missing / 3 n/a when Clipy or the Automator stub is absent. CI listed its lint targets by hand, so a new script shipped unlinted. Derive them from git ls-files instead, for shellcheck, zsh -n, the Brewfiles, and plutil. Smaller cleanups along the way: - FileVault was reported by both defaults.sh and doctor.sh. Drop the defaults.sh copy; drift reporting belongs in doctor.sh, and the README now says so. - Replace bootstrap.sh's read/[[ =~ ]] prompt pairs with confirm() and interactive(), so the --yes and no-tty cases are decided in one place. - --help was sed -n '2,10p' of the script's own header, silently wrong the moment a line moved. Use a usage() heredoc. - Add doctor.sh's detail() for the four places that indented captured output line by line. - Drop bootstrap.sh's trailing ~/.zshrc check: readlink -f against a glob inside [[ ]] is a literal comparison that never matched, and the stow section already reports this. - Replace basename with parameter expansion in the login-item and Dock paths, and compare Dock names without grep so an app name containing regex metacharacters can't misreport. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AY9x8Cs9gZTgMMRfsW23ay --- .github/workflows/lint.yml | 14 ++--- README.md | 6 +- bootstrap.sh | 110 ++++++++++++++++-------------------- doctor.sh | 49 +++++++--------- lib.sh | 66 ++++++++++++++++++++++ macos/README.md | 10 +++- macos/build-launch-clipy.sh | 47 +++++++++++---- macos/defaults.sh | 36 +++++++----- macos/login-items.sh | 3 +- 9 files changed, 214 insertions(+), 127 deletions(-) create mode 100644 lib.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2376783..5e5c408 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index 856062f..ac97719 100644 --- a/README.md +++ b/README.md @@ -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 | --- @@ -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 @@ -261,7 +262,8 @@ stow --restow --target="$HOME" 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. --- diff --git a/bootstrap.sh b/bootstrap.sh index 053f610..61b4f11 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -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 @@ -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 — 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..." @@ -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 @@ -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 @@ -196,13 +195,9 @@ 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 @@ -210,11 +205,6 @@ if [[ "$PLATFORM" == "macos" ]] && (( APPLY_DEFAULTS )); then 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." diff --git a/doctor.sh b/doctor.sh index ac4bb1d..ece0a61 100755 --- a/doctor.sh +++ b/doctor.sh @@ -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 @@ -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" @@ -87,9 +82,9 @@ 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)" @@ -97,7 +92,7 @@ else 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 @@ -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 @@ -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" @@ -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 diff --git a/lib.sh b/lib.sh new file mode 100644 index 0000000..099eff5 --- /dev/null +++ b/lib.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Definitions shared by bootstrap.sh and doctor.sh — what this repo manages and +# which tier is active. Source this file; it defines things and runs nothing. +# +# Kept here so the two scripts can never disagree about the package set, the +# in-scope Brewfiles, or how the machine tier is read and written. +# +# Every variable here is consumed by the sourcing script, which shellcheck +# can't see from this file — hence the file-wide SC2034 exemption. +# shellcheck disable=SC2034 + +# Guard against double-sourcing. +[[ -n "${_DOTFILES_LIB_SH:-}" ]] && return 0 +_DOTFILES_LIB_SH=1 + +DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MACHINE_FILE="$DOTFILES_DIR/.machine" + +# Stow packages carrying no special options. +STOW_PACKAGES=(zsh git tmux nvim base16 claude) + +# ssh is stowed on its own: --no-folding keeps ~/.ssh a real directory holding 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 working tree. +SSH_STOW_OPTS=(--no-folding) + +# ---- Platform ---- +if [[ "$(uname)" == "Darwin" ]]; then + PLATFORM="macos" +elif grep -qi microsoft /proc/version 2>/dev/null; then + PLATFORM="wsl" +else + PLATFORM="linux" +fi + +# ---- Machine tier ---- + +# load_machine_tier — set PERSONAL from $MACHINE_FILE, defaulting to core-only +# when the file is absent or carries no value. +load_machine_tier() { + # Reset first: the tier must come from the file, never from an inherited + # PERSONAL in the caller's environment. + PERSONAL=no + if [[ -f "$MACHINE_FILE" ]]; then + # shellcheck source=/dev/null + source "$MACHINE_FILE" + fi + PERSONAL="${PERSONAL:-no}" +} + +# save_machine_tier — record the resolved tier so later runs reuse the answer. +save_machine_tier() { + echo "PERSONAL=$PERSONAL" > "$MACHINE_FILE" +} + +# tier_label — human-readable name for the resolved tier. +tier_label() { + [[ "$PERSONAL" == yes ]] && echo "core + personal" || echo "core only" +} + +# load_brewfiles — populate BREWFILES with the Brewfiles this tier installs. +load_brewfiles() { + BREWFILES=("$DOTFILES_DIR/Brewfile") + [[ "$PERSONAL" == yes ]] && BREWFILES+=("$DOTFILES_DIR/Brewfile.personal") + return 0 +} diff --git a/macos/README.md b/macos/README.md index 10a3699..802137b 100644 --- a/macos/README.md +++ b/macos/README.md @@ -8,7 +8,7 @@ One-time system setup for fresh macOS installs. Not a stow package. |-------------------------------|--------------------------------------------------------------| | `defaults.sh` | Apply (or check) system defaults — Dock, keyboard, Finder, security | | `login-items.sh` | Sourced helpers: `add_login_item`, `has_login_item` | -| `build-launch-clipy.sh` | Build the Launch Clipy Automator app & register login item | +| `build-launch-clipy.sh` | Build (or `--check`) the Launch Clipy Automator app | | `launch-clipy/document.wflow` | Automator workflow source — runs `open -a Clipy` | | `launch-clipy/Info.plist` | App bundle metadata for the Launch Clipy app | @@ -51,7 +51,7 @@ with an explicit check branch. - **Screenshots** — PNG, no window shadow, no floating thumbnail, saved to `~/Desktop/Screenshots` - **Screen saver** — password required immediately. *Best-effort:* since Ventura this pane is partly system-managed and the write may not stick; `--check` will show it as drift if so. - **Menu bar** — Control Center icon and Now Playing visible; clock shows AM/PM and day of week -- **Security** — Touch ID for `sudo`, application firewall with stealth mode. FileVault is **reported only**, never enabled automatically — turning it on generates a recovery key a human has to record. +- **Security** — Touch ID for `sudo`, application firewall with stealth mode. FileVault is never touched here — turning it on generates a recovery key a human has to record, so `doctor.sh` reports its status instead. - **SizeUp** — menu bar icon hidden, no popup on disabled state - **Clipy** — status item hidden (hotkey-only access) - **Login items** — SizeUp, Mullvad VPN, Amphetamine, Ice, and Launch Clipy registered automatically; apps that aren't installed are skipped @@ -90,7 +90,11 @@ Clipy has no built-in "launch at login" option, so the workflow is wrapped in a 1. `build-launch-clipy.sh` constructs the `.app` bundle by copying the system's `Automator Application Stub` binary from `/System/Library/CoreServices/` and pairing it with `launch-clipy/document.wflow` + `Info.plist`. 2. The workflow runs `open -a Clipy` via the Run Shell Script action. -3. `add_login_item` (from `login-items.sh`) registers the resulting app as a hidden Login Item. +3. The built app is a row in `defaults.sh`'s `LOGIN_ITEM_APPS`, so the normal login-item pass registers it as hidden. + +`--check` covers both halves: `build-launch-clipy.sh --check` reports whether the +applet exists (it stays quiet when Clipy itself isn't installed, since there is +nothing to build), and the `LOGIN_ITEM_APPS` row reports whether it's registered. The build is skipped with a message if Clipy isn't installed or if Apple has moved the Automator stub. Editing the launcher behavior is a matter of changing diff --git a/macos/build-launch-clipy.sh b/macos/build-launch-clipy.sh index e7a9280..b46f348 100755 --- a/macos/build-launch-clipy.sh +++ b/macos/build-launch-clipy.sh @@ -1,30 +1,55 @@ #!/usr/bin/env bash -# Build ~/Applications/Launch Clipy.app from the Automator workflow source -# and register it as a login item. Safe to re-run. +# Build ~/Applications/Launch Clipy.app from the Automator workflow source. +# +# bash macos/build-launch-clipy.sh build it (safe to re-run) +# bash macos/build-launch-clipy.sh --check report whether it is built: +# 0 built, 1 missing, 3 n/a +# +# Registering the result as a login item is defaults.sh's job — it is a row in +# LOGIN_ITEM_APPS. The applet's own existence can't ride on that row, because an +# unbuilt applet is indistinguishable from an uninstalled app there, so --check +# lives here instead. # # Clipy has no built-in "launch at login" option, so the workflow is wrapped in # a tiny Automator applet that runs `open -a Clipy` and quits. set -euo pipefail +MODE=build +case "${1:-}" in + --check) MODE=check ;; + "") ;; + *) echo "usage: ${0##*/} [--check]" >&2; exit 2 ;; +esac + HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SRC="$HERE/launch-clipy" APP="$HOME/Applications/Launch Clipy.app" STUB="/System/Library/CoreServices/Automator Application Stub.app/Contents/MacOS/Automator Application Stub" -# shellcheck source=macos/login-items.sh -source "$HERE/login-items.sh" +# Nothing to build, and nothing to report as drift, when the app it launches +# isn't installed or Apple has moved the stub the applet is built from. Check +# mode exits 3 for those so callers don't mistake "can't build" for "built". +NA=0 +[[ "$MODE" == check ]] && NA=3 if [[ ! -e "/Applications/Clipy.app" ]]; then - echo " - Clipy not installed — skipping Launch Clipy build" - exit 0 + [[ "$MODE" == build ]] && echo " - Clipy not installed — skipping Launch Clipy build" + exit "$NA" fi if [[ ! -x "$STUB" ]]; then - echo " ! Automator Application Stub not found at:" >&2 - echo " $STUB" >&2 - echo " Skipping Launch Clipy build." >&2 - exit 0 + if [[ "$MODE" == build ]]; then + echo " ! Automator Application Stub not found at:" >&2 + echo " $STUB" >&2 + echo " Skipping Launch Clipy build." >&2 + fi + exit "$NA" +fi + +if [[ "$MODE" == check ]]; then + [[ -e "$APP" ]] && exit 0 + exit 1 fi mkdir -p "$APP/Contents/MacOS" @@ -32,4 +57,4 @@ cp "$SRC/Info.plist" "$APP/Contents/Info.plist" cp "$SRC/document.wflow" "$APP/Contents/document.wflow" cp "$STUB" "$APP/Contents/MacOS/Automator Application Stub" -add_login_item "$APP" true || true +echo " ✓ Launch Clipy built at $APP" diff --git a/macos/defaults.sh b/macos/defaults.sh index 034b5c7..9debd76 100755 --- a/macos/defaults.sh +++ b/macos/defaults.sh @@ -122,12 +122,15 @@ DOCK_APPS=( "/Applications/Ghostty.app" ) -# Apps to register as login items. +# Apps to register as login items. Launch Clipy is built by +# build-launch-clipy.sh just before the login-item pass; listing it here rather +# than registering it there keeps every login item under one --check. LOGIN_ITEM_APPS=( "/Applications/SizeUp.app" "/Applications/Mullvad VPN.app" "/Applications/Amphetamine.app" "/Applications/Ice.app" + "$HOME/Applications/Launch Clipy.app" ) # Caps Lock → Left Control, as an HID modifier mapping. @@ -230,10 +233,11 @@ process_dock_apps() { fi if [[ "$MODE" == check ]]; then - local listed missing=0 + local listed name missing=0 listed="$(dockutil --list 2>/dev/null || true)" for app in "${present[@]}"; do - grep -qF "$(basename "$app" .app)" <<<"$listed" || { missing=1; break; } + name="${app##*/}" + [[ "$listed" == *"${name%.app}"* ]] || { missing=1; break; } done if (( missing )); then note_drift "Dock contents differ from DOCK_APPS" @@ -257,7 +261,8 @@ process_login_items() { local app name for app in "${LOGIN_ITEM_APPS[@]}"; do [[ -e "$app" ]] || continue - name="$(basename "$app" .app)" + name="${app##*/}" + name="${name%.app}" if [[ "$MODE" == check ]]; then if has_login_item "$app"; then @@ -317,16 +322,6 @@ process_firewall() { echo " ✓ Application firewall enabled" } -# Report only — enabling FileVault generates a recovery key that a human has -# to record, so it is never automated here. -report_filevault() { - if fdesetup status 2>/dev/null | grep -q "FileVault is On"; then - note_ok "FileVault" - else - note_drift "FileVault is off — enable it in System Settings → Privacy & Security" - fi -} - # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- @@ -347,10 +342,21 @@ process_caps_lock process_dock_apps process_touch_id_sudo process_firewall -report_filevault +# Build the Launch Clipy applet before the login-item pass, so it is present for +# the LOGIN_ITEM_APPS entry that registers it. In check mode the build script +# reports whether it exists — the LOGIN_ITEM_APPS row can't, since it skips +# missing apps and so cannot tell "never built" from "not installed". if [[ "$MODE" == apply ]]; then bash "$HERE/build-launch-clipy.sh" +else + clipy_rc=0 + bash "$HERE/build-launch-clipy.sh" --check || clipy_rc=$? + case "$clipy_rc" in + 0) note_ok "Launch Clipy applet built" ;; + 1) note_drift "Launch Clipy applet not built" ;; + *) ;; # n/a — Clipy or the Automator stub is missing + esac fi process_login_items diff --git a/macos/login-items.sh b/macos/login-items.sh index f0909f7..fbb05f0 100755 --- a/macos/login-items.sh +++ b/macos/login-items.sh @@ -29,7 +29,8 @@ EOF # Returns 0 on success, 1 if the app is missing or registration failed. add_login_item() { local app="$1" hidden="${2:-true}" name - name="$(basename "$app" .app)" + name="${app##*/}" + name="${name%.app}" if [[ ! -e "$app" ]]; then echo " - $name not installed — skipping login item"