diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e66dabc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,74 @@ +name: test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: unit (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install zsh and jq + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends zsh jq + else + command -v jq >/dev/null || brew install jq + fi + + - name: Run unit tests + run: ./tests/run + + container: + name: install (${{ matrix.base }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + base: + - debian:bookworm-slim + - fedora:41 + # The runner is x86_64; the arch-alt scenario covers arm64 asset picking + # without paying for emulation. + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run install scenarios in a container + run: ./tests/run --container --base "${{ matrix.base }}" + + shellcheck: + name: shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + # Errors only: warnings in these scripts are mostly stylistic. + - name: Lint shell scripts + run: | + mapfile -t files < <( + find install scripts tests -type f ! -name '*.zip' ! -name '*.md' \ + | while read -r f; do + head -c 200 "$f" | grep -qE '^#!.*(bash|/sh)' && echo "$f" + done + ) + printf 'checking %d files\n' "${#files[@]}" + shellcheck --severity=error --external-sources "${files[@]}" diff --git a/README.md b/README.md index 9679141..8d270fb 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,21 @@ This sets up my profile & dev environment that I use across all my machines. This script will clone the dotfiles repo to `~/.dotfiles` and link all the config files plus install all the packages I use. ```sh -curl -fsSL https://raw.githubusercontent.com/jsg2021/dotfiles/main/install | sh +curl -fsSL https://raw.githubusercontent.com/jsg2021/dotfiles/main/install | bash ``` ## Signing commits My commit signing is handled by 1Password. If you use a different signing method, you should update the `gpg` blocks in the gitconfig. However, if you also use 1Password, you can use my setup, just set the `signingkey` property in `configs/gitconfig` to your public key. + +## Tests + +`./install` is exercised end to end inside a disposable container, so a broken +installer is caught before it touches a real machine. + +```sh +./tests/run # fast checks, no Docker +./tests/run --container # full install run in a sandbox +``` + +See [tests/README.md](tests/README.md). diff --git a/configs/.config/ghostty/setup.sh b/configs/.config/ghostty/setup.sh index 0ab7472..8897cd0 100755 --- a/configs/.config/ghostty/setup.sh +++ b/configs/.config/ghostty/setup.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash PLATFORM=macos if [[ "$OSTYPE" != "darwin"* ]]; then exit 0; diff --git a/install b/install index 515903c..ba27735 100755 --- a/install +++ b/install @@ -24,6 +24,15 @@ set -e # git submodule update --init --recursive fi + # When piped (curl | bash) $0 is not a real path, so $CWD is junk. + # The clone above guarantees $DIR, so fall back to it. + if [ ! -f "$CWD/scripts/installers/helpers" ]; then + CWD="$DIR" + fi + + # Everything below (git checks, stow's .stowrc) assumes we sit in the repo. + cd $CWD + source $CWD/scripts/installers/helpers #endregion @@ -36,24 +45,23 @@ set -e #region tools if [ "$CODESPACES" = true ]; then echo "Running in codespaces...skip dev os setup" - return - else + else variant=$(uname -s | awk '{print tolower($0)}'); - sh $CWD/scripts/installers/$variant; + "$CWD/scripts/installers/$variant"; fi #endregion # use adopt to prevent stow from complaining about existing files, # since we are going to reset hard anyway - stow configs --adopt # -vvv + stow --dir="$CWD" --target="$HOME" configs --adopt # -vvv if ! is_git_clean; then echo -e "\n\nStashing old .dotfiles that we just replaced... inspect the stash and recover or drop as needed.\n\n"; git stash --include-untracked fi - sh $CWD/scripts/installers/set-zsh + "$CWD/scripts/installers/set-zsh" echo "Installation complete!" } diff --git a/scripts/installers/app-image-launcher b/scripts/installers/app-image-launcher deleted file mode 100644 index 2c62eaa..0000000 --- a/scripts/installers/app-image-launcher +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -# set -x -set -e -CWD=`dirname -- "$( readlink -f -- "$0"; )"` - -{ - # AppImageLauncher service - if ! command -v AppImageLauncher &> /dev/null; then - - API="https://api.github.com/repos/TheAssassin/AppImageLauncher/releases/latest" - - if command -v dnf &> /dev/null; then - PATH=$(curl -Ls -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $API | jq -r '.assets[] | select(.name | contains("x86_64.rpm")) | .browser_download_url') - sudo dnf install https://github.com$PATH; - return; - fi - - else - echo "AppImageLauncher is already installed." - # prompt to remove: - read -p "Do you want to remove it? (y/n) " -n 1 -r - echo # (optional) move to a new line - if [[ $REPLY =~ ^[Yy]$ ]]; then - if command -v dnf &> /dev/null; then - sudo dnf remove AppImageLauncher; - return; - fi - # TODO: add support for other package managers - fi - fi -} \ No newline at end of file diff --git a/scripts/installers/app/1password b/scripts/installers/app/1password index b562f4c..0969c79 100755 --- a/scripts/installers/app/1password +++ b/scripts/installers/app/1password @@ -2,6 +2,9 @@ # set -x set -e +DIR=$(dirname -- "$(readlink -f -- "$0")") +source "$DIR/../helpers" + if [[ "$OSTYPE" == "darwin"* ]]; then if [ ! -d "/Applications/1Password.app" ]; then ( @@ -13,9 +16,9 @@ if [[ "$OSTYPE" == "darwin"* ]]; then open "1Password Installer.app" ) fi -elif ! command -v 1password &> /dev/null; then +elif ! has_binary 1password; then - if command -v dnf &> /dev/null; then + if has_binary dnf; then #1password repo sudo rpm --import https://downloads.1password.com/linux/keys/1password.asc @@ -23,7 +26,7 @@ elif ! command -v 1password &> /dev/null; then sudo dnf install 1password 1password-cli - elif command -v apt-get &> /dev/null; then + elif has_binary apt-get; then curl -sS https://downloads.1password.com/linux/keys/1password.asc | sudo gpg --dearmor --output /usr/share/keyrings/1password-archive-keyring.gpg echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/amd64 stable main' | sudo tee /etc/apt/sources.list.d/1password.list diff --git a/scripts/installers/app/app-image-launcher b/scripts/installers/app/app-image-launcher new file mode 100755 index 0000000..7f1ef34 --- /dev/null +++ b/scripts/installers/app/app-image-launcher @@ -0,0 +1,76 @@ +#!/bin/bash +# set -x +set -e + +DIR=$(dirname -- "$(readlink -f -- "$0")") +source "$DIR/../helpers" + +REPO=TheAssassin/AppImageLauncher +# The package is lowercase; the binary it ships is not. +PKG=appimagelauncher +BIN=AppImageLauncher + +function require_url() { + if [ -z "$1" ]; then + echo "No $2 asset in the latest AppImageLauncher release." + exit 1 + fi +} + +#region uninstall +if has_binary "$BIN"; then + echo "AppImageLauncher is already installed." + + # Never block an unattended install; only ask when someone is watching. + if [ ! -t 0 ]; then + exit 0 + fi + + read -p "Do you want to remove it? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 0 + fi + + if has_binary dnf; then + sudo dnf remove -y "$PKG" + elif has_binary apt-get; then + sudo apt-get remove -y "$PKG" + else + echo "No supported package manager; remove $PKG by hand." + exit 0 + fi + + exit 0 +fi +#endregion + +#region install +if has_binary dnf; then + + # dnf installs straight from a URL. Assets are ..._x86_64.rpm / + # ..._aarch64.rpm / ..._armv7hl.rpm, matching `uname -m`. + URL=$(github_asset_url "$REPO" "_$(arch_for uname).rpm") + require_url "$URL" "$(arch_for uname) rpm" + sudo dnf install -y "$URL" + +elif has_binary apt-get; then + + # apt cannot fetch a package by URL, so download it first. Installing the + # local file through apt-get (not dpkg) pulls in its dependencies. + # dpkg is authoritative here (it knows about an i386 userland on an amd64 + # kernel); arch_for go is the fallback shape. + ARCH=$(dpkg --print-architecture 2>/dev/null || arch_for go) + URL=$(github_asset_url "$REPO" "_$ARCH.deb") + require_url "$URL" "$ARCH deb" + + download_to ~/Downloads/$PKG.deb "$URL" + sudo apt-get update + sudo apt-get install -y ~/Downloads/$PKG.deb + rm -f ~/Downloads/$PKG.deb + +else + echo "...skipping AppImageLauncher installation $(get_distro_id)" + exit 0 +fi +#endregion diff --git a/scripts/installers/app/ghostty b/scripts/installers/app/ghostty old mode 100644 new mode 100755 index 9471afd..1a006ab --- a/scripts/installers/app/ghostty +++ b/scripts/installers/app/ghostty @@ -3,22 +3,28 @@ set -e DIR=$(dirname -- "$(readlink -f -- "$0")") -source $DIR/../helpers +source "$DIR/../helpers" case $(get_distro_id) in darwin) - brew install --cask ghostty + brew install --cask ghostty ;; fedora|\ debian|\ ubuntu) - mkdir -p ~/Downloads - mkdir -p ~/.local/bin - API="https://api.github.com/repos/pkgforge-dev/ghostty-appimage/releases/latest" - curl -sL -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $API \ - | jq -r ".assets[] | select(.name | endswith(\"AppImage\")) | .browser_download_url" \ - | xargs curl -fsSL -o ~/Downloads/ghostty.AppImage + # The release ships one AppImage per arch (…-aarch64.AppImage, + # …-x86_64.AppImage). Matching on ".AppImage" alone returns both. + SUFFIX="-$(arch_for uname).AppImage" + URL=$(github_asset_url pkgforge-dev/ghostty-appimage "$SUFFIX") + + if [ -z "$URL" ]; then + echo "...skipping ghostty: no AppImage matching *$SUFFIX" + exit 0 + fi + + download_to ~/Downloads/ghostty.AppImage "$URL" chmod +x ~/Downloads/ghostty.AppImage + mkdir -p ~/.local/bin mv -f ~/Downloads/ghostty.AppImage ~/.local/bin/ghostty ;; *) @@ -27,4 +33,4 @@ case $(get_distro_id) in ;; esac -sh $DIR/../../../configs/.config/ghostty/setup.sh \ No newline at end of file +"$DIR/../../../configs/.config/ghostty/setup.sh" diff --git a/scripts/installers/app/github b/scripts/installers/app/github index 6dbf412..6a4f31d 100755 --- a/scripts/installers/app/github +++ b/scripts/installers/app/github @@ -2,22 +2,25 @@ # set -x set -e +DIR=$(dirname -- "$(readlink -f -- "$0")") +source "$DIR/../helpers" -if ! command -v gh &> /dev/null; then - if command -v brew &> /dev/null; then +if ! has_binary gh; then + + if has_binary brew; then brew install gh - elif command -v dnf &> /dev/null; then + elif has_binary dnf; then sudo dnf install 'dnf-command(config-manager)' sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo sudo dnf install gh - elif command -v apt-get &> /dev/null; then + elif has_binary apt-get; then - type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) + has_binary curl || sudo apt-get install -y curl curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ && sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ diff --git a/scripts/installers/app/vscode b/scripts/installers/app/vscode index 2d4d117..0798b96 100755 --- a/scripts/installers/app/vscode +++ b/scripts/installers/app/vscode @@ -2,12 +2,16 @@ # set -x set -e +DIR=$(dirname -- "$(readlink -f -- "$0")") +source "$DIR/../helpers" + if [[ "$OSTYPE" == "darwin"* ]]; then if [ ! -d "/Applications/Visual Studio Code.app" ]; then ( echo "Downloading Visual Studio Code..." cd ~/Downloads - curl -fsSL -o vscode.zip https://code.visualstudio.com/sha/download\?build\=stable\&os\=darwin-arm64 && + # darwin-universal runs on both Intel and Apple Silicon. + curl -fsSL -o vscode.zip "https://code.visualstudio.com/sha/download?build=stable&os=darwin-universal" && unzip vscode.zip && rm vscode.zip sudo mv "./Visual Studio Code.app" "/Applications/Visual Studio Code.app" @@ -16,7 +20,7 @@ if [[ "$OSTYPE" == "darwin"* ]]; then exit 0; fi -if command -v dnf &> /dev/null; then +if has_binary dnf; then sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc sudo sh -c 'echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com/yumrepos/vscode\nenabled=1\ngpgcheck=1\ngpgkey=https://packages.microsoft.com/keys/microsoft.asc" > /etc/yum.repos.d/vscode.repo' @@ -24,7 +28,7 @@ if command -v dnf &> /dev/null; then dnf check-update sudo dnf install code -elif command -v apt &> /dev/null; then +elif has_binary apt-get; then sudo apt install wget gpg wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > packages.microsoft.gpg diff --git a/scripts/installers/app/wezterm b/scripts/installers/app/wezterm old mode 100644 new mode 100755 index a35b7d4..fcbf7e5 --- a/scripts/installers/app/wezterm +++ b/scripts/installers/app/wezterm @@ -3,26 +3,71 @@ set -e DIR=$(dirname -- "$(readlink -f -- "$0")") -source $DIR/../helpers +source "$DIR/../helpers" -case $(get_distro_id) in +DISTRO=$(get_distro_id) + +case $DISTRO in darwin) - brew install --cask wezterm - ;; - fedora|\ - debian|\ - ubuntu) - mkdir -p ~/Downloads - mkdir -p ~/.local/bin - API="https://api.github.com/repos/wezterm/wezterm/releases/latest" - curl -sL -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $API \ - | jq -r ".assets[] | select(.name | endswith(\"AppImage\")) | .browser_download_url" \ - | xargs curl -fsSL -o ~/Downloads/wezterm.AppImage - chmod +x ~/Downloads/wezterm.AppImage - mv -f ~/Downloads/wezterm.AppImage ~/.local/bin/wezterm + brew install --cask wezterm + exit 0 ;; + fedora|debian|ubuntu) ;; *) - echo "...skipping wezterm installation $(get_distro_id)" + echo "...skipping wezterm installation $DISTRO" exit 0 ;; esac + +# Upstream builds the AppImage on x86_64 only. Other arches get .deb packages, +# so an AppImage download would hand an arm64 box an x86_64 binary. +if [ "$(arch_for uname)" = "x86_64" ]; then + + URL=$(github_asset_url wezterm/wezterm ".AppImage") + + if [ -z "$URL" ]; then + echo "...skipping wezterm: no x86_64 AppImage in the latest release" + exit 0 + fi + + download_to ~/Downloads/wezterm.AppImage "$URL" + chmod +x ~/Downloads/wezterm.AppImage + mkdir -p ~/.local/bin + mv -f ~/Downloads/wezterm.AppImage ~/.local/bin/wezterm + +elif has_binary apt-get; then + + # Assets are tagged with the distro they were built on, e.g. + # wezterm-.Debian12.arm64.deb. Prefer a build for this release, then + # settle for any build of the right arch. + ARCH=$(arch_for go) + VERSION_ID=$(awk -F= '/^VERSION_ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release 2>/dev/null) + + case "$DISTRO" in + debian) TAG=".Debian${VERSION_ID%%.*}.$ARCH.deb" ;; + ubuntu) TAG=".Ubuntu${VERSION_ID}.$ARCH.deb" ;; + *) TAG="" ;; + esac + + URL='' + if [ -n "$TAG" ]; then + URL=$(github_asset_url wezterm/wezterm "$TAG") + fi + if [ -z "$URL" ]; then + URL=$(github_asset_url wezterm/wezterm "$ARCH.deb") + fi + + if [ -z "$URL" ]; then + echo "...skipping wezterm: no $ARCH .deb in the latest release" + exit 0 + fi + + download_to ~/Downloads/wezterm.deb "$URL" + sudo apt-get update + sudo apt-get install -y ~/Downloads/wezterm.deb + rm -f ~/Downloads/wezterm.deb + +else + echo "...skipping wezterm: no $(arch_for uname) build available for $DISTRO" + exit 0 +fi diff --git a/scripts/installers/apps b/scripts/installers/apps old mode 100644 new mode 100755 index bc78044..ed74bb2 --- a/scripts/installers/apps +++ b/scripts/installers/apps @@ -10,8 +10,11 @@ if [ "$CODESPACES" = true ]; then exit 0; fi -sh $DIR/app/1password -sh $DIR/app/github -sh $DIR/app/vscode -sh $DIR/app/ghostty -sh $DIR/app/wezterm \ No newline at end of file +"$DIR/app/1password" +"$DIR/app/github" +"$DIR/app/vscode" +"$DIR/app/ghostty" +"$DIR/app/wezterm" + +# app-image-launcher is deliberately not run here; it is a manual, interactive +# script. Run it yourself: ~/.dotfiles/scripts/installers/app/app-image-launcher \ No newline at end of file diff --git a/scripts/installers/darwin b/scripts/installers/darwin old mode 100644 new mode 100755 index 01c7eae..dacb32d --- a/scripts/installers/darwin +++ b/scripts/installers/darwin @@ -5,5 +5,5 @@ set -e DIR=$(dirname -- "$(readlink -f -- "$0")") source $DIR/helpers -sh $DIR/homebrew -sh $DIR/tools \ No newline at end of file +"$DIR/homebrew" +"$DIR/tools" \ No newline at end of file diff --git a/scripts/installers/diff-so-fancy b/scripts/installers/diff-so-fancy index 5af0261..59f12cb 100755 --- a/scripts/installers/diff-so-fancy +++ b/scripts/installers/diff-so-fancy @@ -2,22 +2,26 @@ # set -x set -e -API="https://api.github.com/repos/so-fancy/diff-so-fancy/releases/latest" +APP=diff-so-fancy DIR=$(cd -- "$(dirname -- "$0")" && pwd) source "$DIR/helpers" # has_binary, not command -v: this script shares its name with the binary it # installs, so a plain lookup can find the script itself and skip the install. -if ! has_binary diff-so-fancy; then - mkdir -p ~/Downloads - curl -sL -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $API \ - | jq -r '.assets[] | select(.name | contains("diff-so-fancy")) | .browser_download_url' \ - | xargs curl -fsSL -o ~/Downloads/diff-so-fancy +if has_binary $APP; then + exit 0 +fi + +# A perl script, so there is nothing arch-specific to choose. +URL=$(github_asset_url so-fancy/$APP "$APP") - chmod +x ~/Downloads/diff-so-fancy - mkdir -p ~/.local/bin - mv ~/Downloads/diff-so-fancy ~/.local/bin/diff-so-fancy -# else - # echo "diff-so-fancy is already installed, to update remove it first and run this script again" +if [ -z "$URL" ]; then + echo "No $APP release asset found" + exit 1 fi + +download_to ~/Downloads/$APP "$URL" +chmod +x ~/Downloads/$APP +mkdir -p ~/.local/bin +mv -f ~/Downloads/$APP ~/.local/bin/$APP diff --git a/scripts/installers/diffnav b/scripts/installers/diffnav index 4dc6c2e..fb47251 100755 --- a/scripts/installers/diffnav +++ b/scripts/installers/diffnav @@ -3,30 +3,34 @@ set -e APP=diffnav -API="https://api.github.com/repos/dlvhdr/diffnav/releases/latest" -OS_AND_ARCH=$(uname -s)_$(uname -m) DIR=$(cd -- "$(dirname -- "$0")" && pwd) source "$DIR/helpers" # has_binary, not command -v: this script shares its name with the binary it # installs, so a plain lookup can find the script itself and skip the install. -if ! has_binary $APP; then - if has_binary brew; then - brew install $APP - exit 0 - fi +if has_binary $APP; then + exit 0 +fi - mkdir -p ~/Downloads - curl -sL -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $API \ - | jq -r ".assets[] | select(.name | contains(\"$OS_AND_ARCH\")) | .browser_download_url" \ - | xargs curl -fsSL -o ~/Downloads/$APP.tar.gz - - tar -xzf ~/Downloads/$APP.tar.gz -C ~/Downloads - chmod +x ~/Downloads/$APP - mkdir -p ~/.local/bin - mv ~/Downloads/$APP ~/.local/bin/$APP - rm ~/Downloads/$APP.tar.gz -# else - # echo "$APP is already installed, to update remove it first and run this script again" +if has_binary brew; then + brew install $APP + exit 0 fi + +# Assets are named diffnav_Linux_x86_64 / diffnav_Linux_arm64 -- goreleaser +# keeps x86_64 but renames aarch64 to arm64, so `uname -m` alone is not enough. +SUFFIX="_$(uname -s)_$(arch_for mixed).tar.gz" +URL=$(github_asset_url dlvhdr/$APP "$SUFFIX") + +if [ -z "$URL" ]; then + echo "No $APP release asset matching *$SUFFIX" + exit 1 +fi + +download_to ~/Downloads/$APP.tar.gz "$URL" +tar -xzf ~/Downloads/$APP.tar.gz -C ~/Downloads +chmod +x ~/Downloads/$APP +mkdir -p ~/.local/bin +mv -f ~/Downloads/$APP ~/.local/bin/$APP +rm -f ~/Downloads/$APP.tar.gz diff --git a/scripts/installers/docker b/scripts/installers/docker index 02b0873..9e14226 100755 --- a/scripts/installers/docker +++ b/scripts/installers/docker @@ -3,8 +3,8 @@ set -e DIR=$(dirname -- "$(readlink -f -- "$0")") -source $DIR/helpers -if [ command -v docker &> /dev/null ]; then +source "$DIR/helpers" +if has_binary docker; then echo "Docker is already installed. Skipping installation." exit 0 fi @@ -31,7 +31,7 @@ case $(get_distro_id) in sudo systemctl enable --now docker ;; darwin) - open https://desktop.docker.com/mac/main/arm64/Docker.dmg + open "https://desktop.docker.com/mac/main/$(arch_for go)/Docker.dmg" exit 0; ;; *) diff --git a/scripts/installers/helpers b/scripts/installers/helpers index 64404cc..bdd75a4 100644 --- a/scripts/installers/helpers +++ b/scripts/installers/helpers @@ -33,22 +33,103 @@ function map_get() { return 0 } -# Looks for a binary, ignoring "." and the installers dir on PATH. -# Without this, a local installer script shadows the binary it installs. -function has_binary() { +# PATH with "." and our own installer dirs removed. Without this, an installer +# script named after the binary it installs (docker, diffnav, ghostty...) would +# look like the binary itself. +function _lookup_path() { local entry - local clean_path='' + local cleaned='' local -a parts IFS=':' read -r -a parts <<< "$PATH" for entry in "${parts[@]}"; do case "$entry" in - ''|'.'|"$INSTALLERS_DIR") continue ;; + ''|'.'|"$INSTALLERS_DIR"|"$INSTALLERS_DIR/app") continue ;; esac - clean_path="${clean_path:+$clean_path:}$entry" + cleaned="${cleaned:+$cleaned:}$entry" done - PATH="$clean_path" command -v "$1" &>/dev/null + echo "$cleaned" +} + +# Resolves a binary to its full path, or prints nothing if it is not installed. +# Use this instead of `which`, which is missing on minimal images. +function binary_path() { + PATH="$(_lookup_path)" command -v "$1" 2>/dev/null +} + +# The one way to ask "is this installed?". Use this instead of `command -v`. +function has_binary() { + PATH="$(_lookup_path)" command -v "$1" &>/dev/null +} + +# Release assets are named with several different arch vocabularies. Ask for +# the one the project actually uses instead of guessing. +# +# uname x86_64 aarch64 armv7l -- `uname -m`; AppImages, rpms +# go amd64 arm64 arm -- Go/goreleaser, Docker, .deb +# mixed x86_64 arm64 -- goreleaser's default asset template +function arch_for() { + local m + m=$(uname -m) + + case "$1" in + uname) + echo "$m" + ;; + go) + case "$m" in + x86_64|amd64) echo amd64 ;; + aarch64|arm64) echo arm64 ;; + armv7l|armhf) echo arm ;; + *) echo "$m" ;; + esac + ;; + mixed) + case "$m" in + x86_64|amd64) echo x86_64 ;; + aarch64|arm64) echo arm64 ;; + *) echo "$m" ;; + esac + ;; + *) + echo "arch_for: unknown flavour '$1'" >&2 + return 1 + ;; + esac +} + +# github_asset_url +# The download URL of the latest release asset whose name ends with . +# Prints nothing when nothing matches, so callers must check before using it. +# Always match on a suffix that pins the architecture; several projects ship +# one asset per arch and a loose filter silently returns more than one. +function github_asset_url() { + local urls count + + urls=$(curl -sL -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$1/releases/latest" \ + | jq -r --arg suffix "$2" \ + '.assets[] | select(.name | endswith($suffix)) | .browser_download_url') + + count=$(printf '%s' "$urls" | grep -c . || true) + + # Two matches means the filter is not pinning the architecture. Downloading + # whichever came back first is how you end up with an x86_64 binary on arm. + if [ "$count" -gt 1 ]; then + echo "github_asset_url: '$2' matched $count assets in $1:" >&2 + printf '%s\n' "$urls" | sed 's/^/ /' >&2 + return 1 + fi + + printf '%s' "$urls" +} + +# download_to +function download_to() { + mkdir -p "$(dirname "$1")" + curl -fsSL -o "$1" "$2" } function install_my_tool() { @@ -63,19 +144,23 @@ function install_my_tool() { # A local installer script wins over the package manager. if [ -f "$INSTALLERS_DIR/$bin" ]; then - bash "$INSTALLERS_DIR/$bin" + "$INSTALLERS_DIR/$bin" continue fi pkg=$(map_get "$bin" "${BINARY_PKG_ALIASES[@]}") COMMAND='' - if command -v brew &>/dev/null; then + if has_binary brew; then COMMAND="brew" - elif command -v dnf &>/dev/null; then + elif has_binary dnf; then COMMAND="sudo dnf -y" - pkg=$(map_get "$bin" "${DNF_BINARY_PKG_ALIASES[@]}") - elif command -v apt-get &>/dev/null; then + # The dnf map only overrides; fall back to the shared aliases. + dnf_pkg=$(map_get "$bin" "${DNF_BINARY_PKG_ALIASES[@]}") + if [ "$dnf_pkg" != "$bin" ]; then + pkg="$dnf_pkg" + fi + elif has_binary apt-get; then COMMAND="sudo apt-get -y" fi diff --git a/scripts/installers/homebrew b/scripts/installers/homebrew old mode 100644 new mode 100755 index fffa6a3..000dba4 --- a/scripts/installers/homebrew +++ b/scripts/installers/homebrew @@ -2,7 +2,10 @@ # set -x set -e -if ! command -v brew &>/dev/null; then +DIR=$(dirname -- "$(readlink -f -- "$0")") +source "$DIR/helpers" + +if ! has_binary brew; then bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # eval "$(/opt/homebrew/bin/brew shellenv)" eval "$(/opt/homebrew/bin/brew shellenv zsh)" diff --git a/scripts/installers/linux b/scripts/installers/linux old mode 100644 new mode 100755 index db627c7..0aa94ee --- a/scripts/installers/linux +++ b/scripts/installers/linux @@ -7,4 +7,4 @@ source $DIR/helpers install_my_tool xclip xsel -sh $DIR/tools \ No newline at end of file +"$DIR/tools" \ No newline at end of file diff --git a/scripts/installers/set-zsh b/scripts/installers/set-zsh old mode 100644 new mode 100755 index bea4e33..2c6431a --- a/scripts/installers/set-zsh +++ b/scripts/installers/set-zsh @@ -2,10 +2,21 @@ # set -x set -e +DIR=$(dirname -- "$(readlink -f -- "$0")") +source "$DIR/helpers" + +# binary_path, not `which`: `which` is missing on minimal images (Fedora 41+). +ZSH_PATH=$(binary_path zsh) + +if [ -z "$ZSH_PATH" ]; then + echo "zsh is not installed; skipping login shell change." + exit 0 +fi + # set shell -if [[ "$SHELL" != $(which zsh) ]]; then - chsh -s $(which zsh) +if [[ "$SHELL" != "$ZSH_PATH" ]]; then + chsh -s "$ZSH_PATH" if [[ "$OSTYPE" != "darwin"* ]]; then echo "Reboot for SHELL change to take effect." fi -fi \ No newline at end of file +fi diff --git a/scripts/installers/tools b/scripts/installers/tools old mode 100644 new mode 100755 index ec8fa83..e807dbc --- a/scripts/installers/tools +++ b/scripts/installers/tools @@ -3,17 +3,17 @@ set -e DIR=$(dirname -- "$(readlink -f -- "$0")") -source $DIR/helpers +source "$DIR/helpers" -sh $DIR/apps +"$DIR/apps" install_my_tool bat fzf fd lsd entr rg stow zoxide tmux nvim delta diffnav diff-so-fancy -if ! command -v fnm &>/dev/null; then +if ! has_binary fnm; then curl -fsSLf https://fnm.vercel.app/install | bash fi -if ! command -v atuin &>/dev/null; then +if ! has_binary atuin; then curl -LsSLf https://github.com/atuinsh/atuin/releases/latest/download/atuin-installer.sh | sh fi diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..df969d2 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,94 @@ +# Install test harness + +Runs `./install` and the installer scripts against a throwaway Linux container, +so a broken install is caught before it wrecks a real machine. + +Nothing here touches your machine. The repo is mounted **read-only** at `/src`; +the container copies it to `~/.dotfiles` inside itself and works on that. + +## Running + +```sh +./tests/run # unit tests only, fast, no Docker +./tests/run --container # unit tests + all scenarios (Debian) +./tests/run --container --base fedora:41 # same, on the dnf path +./tests/run --container --platform linux/amd64 # emulate the other CPU arch +./tests/run --container fresh dirty-repo # just those scenarios +./tests/run --shell # interactive poke-around in the sandbox +./tests/run --container --real fresh # no stubs: real network, real packages +``` + +CI runs all of this — see [.github/workflows/test.yml](../.github/workflows/test.yml). + +## How the sandbox works + +The container has real `git`, `zsh`, `jq`, `stow` and nothing else that matters. +Everything that would download a file, install a package, or need root is +shadowed by a stub in [stubs/bin](stubs/bin) that just records the call to +`/tmp/stub.log`. Tests then assert on that log. `--network none` is passed, so a +missing stub shows up as a failure rather than a slow silent download. + +Only the stubs matching the container's real package manager are kept, so a +Debian box cannot accidentally take the `dnf` branch. + +The fake `curl` serves each GitHub repo its own release listing, carrying every +architecture a real release carries. An installer whose filter does not pin the +architecture therefore matches more than one asset, and `github_asset_url` +refuses rather than guessing. The `arch-alt` scenario re-runs the whole install +with `uname -m` faked to the other architecture, which is far cheaper than +emulating a second platform (`--platform` does that if you want the real thing). + +`--real` drops the stubs and the network block. Slow, and only worth it when you +suspect a stub is lying. + +## Layout + +| Path | What it is | +| --- | --- | +| [run](run) | The entry point. Everything below is driven from here. | +| [lib/harness.sh](lib/harness.sh) | `assert_*` helpers and the PASS/FAIL counter. | +| [unit/](unit/) | Host-side checks: script syntax, JSON validity, exec bits, `helpers` functions. | +| [stubs/bin/](stubs/bin/) | Fake `curl`, `sudo`, `apt-get`, `dnf`, `chsh`, … | +| [container/Dockerfile](container/Dockerfile) | The sandbox image. `--build-arg BASE=` picks the distro. | +| [container/entrypoint.sh](container/entrypoint.sh) | Resets `$HOME`, copies the repo, sets up stubs, runs one scenario. | +| [container/scenarios/](container/scenarios/) | One file per situation being tested. | + +## Checking config links + +The core dotfiles (`.zshrc`, `.gitconfig`, and friends) are asserted by name, so +a silent change to the stow rules cannot pass. Everything else is derived at run +time: the harness asks `stow --simulate` what it would link, then checks each +planned file really resolves from `$HOME` back into the repo. Adding a config to +the repo needs no test change. Anything stow skipped must be covered by a line +in `.stow-local-ignore`, so a config that quietly stops being linked is caught. + +## Scenarios + +| Name | What it proves | +| --- | --- | +| `fresh` | Empty machine: every config gets linked, every installer gets called. | +| `idempotent` | Running install twice is safe and changes nothing. | +| `dirty-repo` | Uncommitted changes abort the install before anything is touched. | +| `existing-configs` | Hand-written dotfiles are adopted, then stashed, not lost. | +| `codespaces` | `CODESPACES=true` links configs but skips OS and app installs. | +| `piped` | The README's `curl … \| bash` path works from any directory. | +| `arch-alt` | The same install while pretending to be the other CPU architecture. | + +## Adding a scenario + +Drop `tests/container/scenarios/.sh` in place and add `` to +`ALL_SCENARIOS` in [run](run). The file is sourced with `$DOT`, `$HOME`, +`$STUB_LOG` and every `assert_*` helper already set up. Use `run_install` from +`_common.sh` to invoke the installer. + +## House rules the unit tests enforce + +- **Installer scripts are executable and called directly** — `"$DIR/tools"`, not + `sh "$DIR/tools"`. They declare `#!/bin/bash`; naming an interpreter overrides + that shebang and silently drops every bashism (this is what broke Debian). +- **`has_binary`, never `command -v`** — one lookup path, so the rule that an + installer script must not shadow the binary it installs applies everywhere. +- **`binary_path`, never `which`** — `which` is not installed on Fedora 41+ or + on slim images. +- **Release assets are chosen with `arch_for` + `github_asset_url`** — never a + hardcoded arch, and never a filter loose enough to match two assets. diff --git a/tests/container/Dockerfile b/tests/container/Dockerfile new file mode 100644 index 0000000..66f9e13 --- /dev/null +++ b/tests/container/Dockerfile @@ -0,0 +1,23 @@ +# Disposable box for exercising ./install end to end. +# Build with --build-arg BASE=fedora:41 to test the dnf path. +ARG BASE=debian:bookworm-slim +FROM ${BASE} + +# Only the tools the install script genuinely needs to be real. +# Everything that reaches the network or root is stubbed at run time. +RUN set -eux; \ + if command -v apt-get >/dev/null; then \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + bash git zsh jq stow tar gzip ca-certificates procps file; \ + rm -rf /var/lib/apt/lists/*; \ + else \ + dnf -y install bash git zsh jq stow tar gzip procps-ng file; \ + dnf clean all; \ + fi + +ENV HOME=/root +WORKDIR /root +COPY entrypoint.sh /usr/local/bin/run-scenario +RUN chmod +x /usr/local/bin/run-scenario +ENTRYPOINT ["/usr/local/bin/run-scenario"] diff --git a/tests/container/entrypoint.sh b/tests/container/entrypoint.sh new file mode 100755 index 0000000..fc0d1ea --- /dev/null +++ b/tests/container/entrypoint.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Runs one scenario against a throwaway copy of the repo. +# The host repo is mounted read-only at /src and never modified. +set -uo pipefail + +SCENARIO="${1:?usage: run-scenario }" + +export HOME=/root +export DOT="$HOME/.dotfiles" +export STUB_LOG=/tmp/stub.log +export DEBIAN_FRONTEND=noninteractive + +# --- fresh home ------------------------------------------------------------ +rm -rf "$DOT" "$HOME/.config" "$HOME/.claude" "$HOME/.local" \ + "$HOME/.zshrc" "$HOME/.vimrc" "$HOME/.gitconfig" "$HOME/.bash_profile" \ + /workspaces +: > "$STUB_LOG" + +cp -a /src "$DOT" +rm -f "$DOT/.git/index.lock" + +# Keep the harness's git identity out of $HOME so scenarios can assert that +# ~/.gitconfig only appears when stow puts it there. +export GIT_CONFIG_GLOBAL=/tmp/harness.gitconfig +: > "$GIT_CONFIG_GLOBAL" +git config --global user.email "test@example.invalid" +git config --global user.name "Install Harness" +git config --global init.defaultBranch main +git config --global --add safe.directory "$DOT" + +# Normalise to a clean baseline so a dirty host checkout cannot skew results. +if [ -d "$DOT/.git" ]; then + git -C "$DOT" add -A >/dev/null 2>&1 + git -C "$DOT" commit -qm "harness baseline" >/dev/null 2>&1 +else + git -C "$DOT" init -q + git -C "$DOT" add -A >/dev/null 2>&1 + git -C "$DOT" commit -qm "harness baseline" >/dev/null 2>&1 +fi + +# --- offline sandbox ------------------------------------------------------- +# Stubs shadow every command that would download, install, or need root. +# Only the stubs for THIS distro's package manager are kept: leaving a `dnf` +# stub on a Debian box would send the installers down the wrong branch. +if [ "${REAL_INSTALL:-0}" != "1" ]; then + STUB_BIN=/tmp/stub-bin + rm -rf "$STUB_BIN" + cp -a "$DOT/tests/stubs/bin" "$STUB_BIN" + if command -v apt-get >/dev/null 2>&1; then + rm -f "$STUB_BIN/dnf" "$STUB_BIN/rpm" + else + rm -f "$STUB_BIN/apt" "$STUB_BIN/apt-get" + fi + export PATH="$STUB_BIN:$PATH" +fi +# tmux plugin manager is cloned from GitHub; pretend it is already there. +mkdir -p "$HOME/.tmux/plugins/tpm" + +source "$DOT/tests/lib/harness.sh" + +SCENARIO_FILE="$DOT/tests/container/scenarios/$SCENARIO.sh" +if [ ! -f "$SCENARIO_FILE" ]; then + echo "no such scenario: $SCENARIO" >&2 + echo "available:" >&2 + ls -1 "$DOT/tests/container/scenarios" | sed 's/\.sh$//;s/^/ /' >&2 + exit 2 +fi + +echo "### scenario: $SCENARIO (base: $(cat /etc/os-release 2>/dev/null | awk -F= '/^PRETTY_NAME/{print $2}'))" +source "$SCENARIO_FILE" + +summary diff --git a/tests/container/scenarios/_common.sh b/tests/container/scenarios/_common.sh new file mode 100644 index 0000000..0e750d9 --- /dev/null +++ b/tests/container/scenarios/_common.sh @@ -0,0 +1,157 @@ +# Shared helpers for scenarios. Sourced, never run directly. + +INSTALL_LOG=/tmp/install.log +INSTALL_STATUS= + +# run_install [env assignments...] -- runs ./install from the repo root, +# capturing output to $INSTALL_LOG and the exit code to $INSTALL_STATUS. +function run_install() { + ( cd "$DOT" && env "$@" bash ./install ) > "$INSTALL_LOG" 2>&1 + INSTALL_STATUS=$? + echo " (install exited $INSTALL_STATUS; log: $INSTALL_LOG)" +} + +function show_install_log() { + echo " --- install output (tail) ---" + tail -n 25 "$INSTALL_LOG" | sed 's/^/ | /' + echo " -----------------------------" +} + +# The files you would notice within a minute of opening a new shell. Spelled +# out on purpose: if one of these silently stops being linked, that is a bug +# even if stow thinks the new arrangement is fine. +function assert_core_links() { + assert_symlink "$HOME/.zshrc" ".dotfiles/configs/.zshrc" + assert_symlink "$HOME/.vimrc" ".dotfiles/configs/.vimrc" + assert_symlink "$HOME/.gitconfig" ".dotfiles/configs/.gitconfig" + assert_symlink "$HOME/.bash_profile" ".dotfiles/configs/.bash_profile" + assert_links_into "$HOME/.profile" "/.dotfiles/configs/" + assert_links_into "$HOME/.config/starship.toml" "/.dotfiles/configs/" + assert_links_into "$HOME/.config/nvim/init.lua" "/.dotfiles/configs/" + assert_links_into "$HOME/.config/tmux/tmux.conf" "/.dotfiles/configs/" + assert_links_into "$HOME/.claude/settings.json" "/.dotfiles/configs/" + assert_links_into "$HOME/.claude/CLAUDE.md" "/.dotfiles/configs/" +} + +# Everything else, worked out at run time so adding a config to the repo needs +# no test change. stow itself is asked what it would link -- reimplementing its +# .stow-local-ignore matching here would just be a second thing to get wrong. +# +# Pre-creating the directory skeleton in a scratch target defeats stow's +# "folding" (linking a whole directory at once), so the plan comes back as one +# line per leaf file instead of one line per top-level directory. +# +# stow_plan -- prints each planned link, relative to $HOME +function stow_plan() { + local root="$1" skel=/tmp/stow-skel + + rm -rf "$skel" + mkdir -p "$skel" + ( cd "$root/configs" && find . -type d ) | while read -r d; do + mkdir -p "$skel/$d" + done + + stow --simulate -v2 --dir="$root" --target="$skel" configs 2>&1 \ + | sed -n 's/^LINK: \(.*\) => .*/\1/p' \ + | sort +} + +# assert_every_config_linked [repo-root] +function assert_every_config_linked() { + local root="${1:-$DOT}" + local pkg="$root/configs" + local planned=/tmp/stow-planned.txt + local rel total=0 missing=0 leaked=0 + + stow_plan "$root" > "$planned" + + if [ ! -s "$planned" ]; then + fail "stow has a plan for the configs package" "simulate produced no LINK lines" + return + fi + + while IFS= read -r rel; do + total=$((total + 1)) + if [ "$(readlink -f "$HOME/$rel" 2>/dev/null)" != "$(readlink -f "$pkg/$rel")" ]; then + missing=$((missing + 1)) + [ "$missing" -le 5 ] && printf ' missing: ~/%s\n' "$rel" + fi + done < "$planned" + + if [ "$missing" -eq 0 ]; then + pass "every one of the $total files stow plans resolves back into the repo" + else + fail "every one of the $total files stow plans resolves back into the repo" \ + "$missing did not (first 5 listed above)" + fi + + # The remainder -- whatever .stow-local-ignore held back -- must stay out of + # $HOME, and must be there on purpose. A config that quietly stops being + # linked because someone widened an ignore rule shows up here. + local unexpected=0 rule matched + while IFS= read -r rel; do + if [ -e "$HOME/$rel" ]; then + leaked=$((leaked + 1)) + [ "$leaked" -le 5 ] && printf ' leaked: ~/%s\n' "$rel" + fi + + # stow always ignores its own control file. + [ "$rel" = ".stow-local-ignore" ] && continue + + matched=0 + while IFS= read -r rule; do + [ -z "$rule" ] && continue + case "$rule" in \#*) continue ;; esac + case "$rel" in "$rule"|"$rule"/*) matched=1; break ;; esac + done < "$pkg/.stow-local-ignore" + + if [ "$matched" -eq 0 ]; then + unexpected=$((unexpected + 1)) + [ "$unexpected" -le 5 ] && printf ' not linked, not ignored: %s\n' "$rel" + fi + done < <( ( cd "$pkg" && find . \( -type f -o -type l \) | sed 's|^\./||' | sort ) \ + | comm -23 - "$planned" ) + + if [ "$leaked" -eq 0 ]; then + pass "ignored configs stayed out of \$HOME" + else + fail "ignored configs stayed out of \$HOME" "$leaked leaked (first 5 listed above)" + fi + + if [ "$unexpected" -eq 0 ]; then + pass "every skipped config is covered by a .stow-local-ignore rule" + else + fail "every skipped config is covered by a .stow-local-ignore rule" \ + "$unexpected unaccounted for (first 5 listed above)" + fi +} + +# Anything install created must point at something real. The repo itself is +# pruned: it is a real directory under $HOME, and its contents are git's job. +function assert_no_dangling_links() { + local broken + broken=$(find "$HOME" -maxdepth 4 \ + -path "$DOT" -prune -o \ + -type l ! -exec test -e {} \; -print 2>/dev/null) + if [ -z "$broken" ]; then + pass "no dangling symlinks under \$HOME" + else + fail "no dangling symlinks under \$HOME" "$(echo "$broken" | tr '\n' ' ')" + fi +} + +# .stow-local-ignore and .stowrc must keep these out of $HOME. +function assert_ignored_not_linked() { + assert_missing "$HOME/.stowrc" + assert_missing "$HOME/shell" + assert_missing "$HOME/git" + assert_missing "$HOME/.stow-local-ignore" +} + +function assert_shell_configs_parse() { + assert_ok "zsh parses ~/.zshrc" zsh -n "$HOME/.zshrc" + assert_ok "bash parses ~/.bash_profile" bash -n "$HOME/.bash_profile" + assert_ok "zsh parses configs/shell/vars" zsh -n "$DOT/configs/shell/vars" + assert_ok "zsh parses configs/shell/aliases" zsh -n "$DOT/configs/shell/aliases" + assert_ok "zsh parses configs/shell/prompt" zsh -n "$DOT/configs/shell/prompt" +} diff --git a/tests/container/scenarios/arch-alt.sh b/tests/container/scenarios/arch-alt.sh new file mode 100644 index 0000000..b691818 --- /dev/null +++ b/tests/container/scenarios/arch-alt.sh @@ -0,0 +1,44 @@ +# Same install, but pretending the box is the other CPU architecture. +# Catches asset filters that only happen to be right on the machine you test on. +source "$DOT/tests/container/scenarios/_common.sh" + +case "$(uname -m)" in + x86_64) FAKE=aarch64; MIXED=arm64; OTHER_UNAME=x86_64; OTHER_MIXED=x86_64 ;; + aarch64) FAKE=x86_64; MIXED=x86_64; OTHER_UNAME=aarch64; OTHER_MIXED=arm64 ;; + *) + echo " SKIP no alternate arch mapped for $(uname -m)" + summary + return 0 + ;; +esac + +echo " (real arch $(uname -m); pretending to be $FAKE)" +run_install FAKE_ARCH="$FAKE" +[ "$INSTALL_STATUS" -eq 0 ] || show_install_log + +suite "install completes while pretending to be $FAKE" +assert_eq "install exits 0" "0" "$INSTALL_STATUS" + +suite "assets picked for $FAKE, not for $(uname -m)" +assert_log_has "diffnav_Linux_$MIXED\.tar\.gz" +assert_log_missing "diffnav_Linux_$OTHER_MIXED\.tar\.gz" +assert_log_has "Ghostty-$FAKE\.AppImage" +assert_log_missing "Ghostty-$OTHER_UNAME\.AppImage" + +suite "wezterm follows the arch, not the AppImage default" +if [ "$FAKE" = "x86_64" ]; then + assert_log_has "WezTerm-Ubuntu20\.04\.AppImage" +elif command -v apt-get >/dev/null 2>&1; then + # Upstream ships no arm AppImage, so it must fall back to the .deb. + assert_log_has "wezterm-.*arm64\.deb" + assert_log_missing "WezTerm-Ubuntu20\.04\.AppImage" +else + # rpm distro on arm: no build exists at all, so skip rather than install + # an x86_64 AppImage. + assert_grep "wezterm skipped on $FAKE" "skipping wezterm" "$INSTALL_LOG" + assert_log_missing "WezTerm-Ubuntu20\.04\.AppImage" +fi + +suite "configs still land" +assert_core_links +assert_every_config_linked diff --git a/tests/container/scenarios/codespaces.sh b/tests/container/scenarios/codespaces.sh new file mode 100644 index 0000000..853a40f --- /dev/null +++ b/tests/container/scenarios/codespaces.sh @@ -0,0 +1,41 @@ +# GitHub Codespaces: Codespaces clones the repo to a fixed path and runs +# install from there. Configs must still be linked; OS/app installs are skipped. +source "$DOT/tests/container/scenarios/_common.sh" + +CS_DIR=/workspaces/.codespaces/.persistedshare/dotfiles +mkdir -p "$(dirname "$CS_DIR")" +cp -a "$DOT" "$CS_DIR" +git -C "$CS_DIR" add -A >/dev/null 2>&1 +git -C "$CS_DIR" commit -qm "codespaces baseline" >/dev/null 2>&1 +git config --global --add safe.directory "$CS_DIR" + +( cd "$CS_DIR" && CODESPACES=true bash ./install ) > "$INSTALL_LOG" 2>&1 +INSTALL_STATUS=$? +echo " (install exited $INSTALL_STATUS)" +[ "$INSTALL_STATUS" -eq 0 ] || show_install_log + +suite "install completes in codespaces" +assert_eq "install exits 0" "0" "$INSTALL_STATUS" +assert_grep "announces codespaces mode" "codespaces" "$INSTALL_LOG" + +suite "configs are linked from the codespaces checkout" +assert_symlink "$HOME/.zshrc" "persistedshare/dotfiles/configs/.zshrc" +assert_symlink "$HOME/.vimrc" "persistedshare/dotfiles/configs/.vimrc" +assert_symlink "$HOME/.gitconfig" "persistedshare/dotfiles/configs/.gitconfig" +assert_links_into "$HOME/.config/starship.toml" "persistedshare/dotfiles/configs/" +assert_links_into "$HOME/.claude/settings.json" "persistedshare/dotfiles/configs/" + +suite "stow ignore rules still apply" +assert_ignored_not_linked + +suite "every config in the codespaces checkout (derived from stow's plan)" +assert_every_config_linked "$CS_DIR" +assert_no_dangling_links + +suite "heavy installs were skipped" +assert_log_missing "install .*ripgrep" +assert_log_missing "1password" +assert_log_missing "install .*bat" + +suite "no network clone was attempted" +assert_no_grep "did not try to clone" "Cloning into" "$INSTALL_LOG" diff --git a/tests/container/scenarios/dirty-repo.sh b/tests/container/scenarios/dirty-repo.sh new file mode 100644 index 0000000..e1359b7 --- /dev/null +++ b/tests/container/scenarios/dirty-repo.sh @@ -0,0 +1,16 @@ +# A dirty checkout must abort before anything is linked or installed. +source "$DOT/tests/container/scenarios/_common.sh" + +echo "scribble" >> "$DOT/README.md" + +run_install + +suite "guard rail" +assert_ok "install refuses to run" bash -c "test '$INSTALL_STATUS' -ne 0" +assert_grep "explains why" "not clean" "$INSTALL_LOG" + +suite "nothing was changed" +assert_missing "$HOME/.zshrc" +assert_missing "$HOME/.gitconfig" +assert_ok "no package manager calls" bash -c "test ! -s '$STUB_LOG'" +assert_grep "the local edit is untouched" "scribble" "$DOT/README.md" diff --git a/tests/container/scenarios/existing-configs.sh b/tests/container/scenarios/existing-configs.sh new file mode 100644 index 0000000..6140ddc --- /dev/null +++ b/tests/container/scenarios/existing-configs.sh @@ -0,0 +1,29 @@ +# Upgrading a machine that already has hand-written dotfiles. +# stow --adopt pulls them into the repo, then install stashes them away. +source "$DOT/tests/container/scenarios/_common.sh" + +printf '# my old zshrc\nexport OLD_MARKER=1\n' > "$HOME/.zshrc" +printf '" my old vimrc\n' > "$HOME/.vimrc" + +run_install +[ "$INSTALL_STATUS" -eq 0 ] || show_install_log + +suite "install still completes" +assert_eq "install exits 0" "0" "$INSTALL_STATUS" + +suite "pre-existing files became links to the repo" +assert_core_links +assert_every_config_linked +assert_no_dangling_links + +suite "the old content was rescued, not lost" +assert_ok "a stash entry exists" \ + bash -c "cd '$DOT' && git stash list | grep -q ." +assert_ok "the stash holds the old zshrc content" \ + bash -c "cd '$DOT' && git stash show -p | grep -q 'OLD_MARKER'" + +suite "repo restored to committed state" +assert_ok "git status clean" \ + bash -c "cd '$DOT' && test -z \"\$(git status --porcelain)\"" +assert_grep "repo .zshrc is the tracked one again" "ZINIT_HOME" "$DOT/configs/.zshrc" +assert_no_grep "repo .zshrc lost the adopted content" "OLD_MARKER" "$DOT/configs/.zshrc" diff --git a/tests/container/scenarios/fresh.sh b/tests/container/scenarios/fresh.sh new file mode 100644 index 0000000..15774ee --- /dev/null +++ b/tests/container/scenarios/fresh.sh @@ -0,0 +1,77 @@ +# A brand new machine: nothing in $HOME, clean repo. +source "$DOT/tests/container/scenarios/_common.sh" + +run_install +[ "$INSTALL_STATUS" -eq 0 ] || show_install_log + +suite "install exit status" +assert_eq "install exits 0" "0" "$INSTALL_STATUS" + +suite "stowed config links" +assert_core_links + +suite "stow ignore rules" +assert_ignored_not_linked + +suite "every config in the repo (derived from stow's own plan)" +assert_every_config_linked +assert_no_dangling_links + +suite "shell configs are parseable after linking" +assert_shell_configs_parse + +suite "package manager was driven" +assert_log_has "apt-get .*install .*bat|dnf .*install .*bat" +assert_log_has "install .*fzf" +assert_log_has "install .*ripgrep" +assert_log_has "install .*neovim" +assert_log_has "install .*git-delta" +assert_log_has "install .*xclip" + +ARCH=$(uname -m) +case "$ARCH" in + x86_64) OTHER_UNAME=aarch64; WANT_MIXED=x86_64; OTHER_MIXED=arm64 ;; + aarch64) OTHER_UNAME=x86_64; WANT_MIXED=arm64; OTHER_MIXED=x86_64 ;; + *) OTHER_UNAME=''; WANT_MIXED=$ARCH; OTHER_MIXED='' ;; +esac + +suite "binaries installed by hand (arch: $ARCH)" +assert_exists "$HOME/.local/bin/diffnav" +assert_exists "$HOME/.local/bin/diff-so-fancy" +assert_exists "$HOME/.local/bin/ghostty" +# Upstream only builds the wezterm AppImage for x86_64; other arches get a +# .deb, and nothing at all on rpm distros. +if [ "$ARCH" = "x86_64" ]; then + assert_exists "$HOME/.local/bin/wezterm" +elif command -v apt-get >/dev/null 2>&1; then + assert_log_has "install .*wezterm\.deb" +else + assert_grep "wezterm skipped on $ARCH" "skipping wezterm" "$INSTALL_LOG" + assert_missing "$HOME/.local/bin/wezterm" +fi + +suite "the asset chosen matches this machine's architecture" +# The stub serves every arch a real release carries, so a filter that is not +# arch-specific would match the wrong one -- or two at once. +assert_log_has "diffnav_Linux_$WANT_MIXED\.tar\.gz" +assert_log_has "Ghostty-$ARCH\.AppImage" +if [ -n "$OTHER_UNAME" ]; then + assert_log_missing "diffnav_Linux_$OTHER_MIXED\.tar\.gz" + assert_log_missing "Ghostty-$OTHER_UNAME\.AppImage" +fi + +suite "app installers ran" +assert_log_has "1password" +assert_log_has "gh|github" +assert_log_has "code|vscode" + +suite "login shell" +assert_log_has "chsh -s .*zsh" + +suite "repo left clean" +assert_ok "git status is clean after install" \ + bash -c "cd '$DOT' && git status --porcelain | tee /tmp/dirty.txt | wc -l | grep -qx 0 || { cat /tmp/dirty.txt; false; }" + +suite "no stray files left in the repo" +assert_missing "$DOT/packages.microsoft.gpg" +assert_missing "$DOT/1Password.zip" diff --git a/tests/container/scenarios/idempotent.sh b/tests/container/scenarios/idempotent.sh new file mode 100644 index 0000000..8965ea2 --- /dev/null +++ b/tests/container/scenarios/idempotent.sh @@ -0,0 +1,26 @@ +# Running install twice must be safe and leave the same result. +source "$DOT/tests/container/scenarios/_common.sh" + +run_install +first=$INSTALL_STATUS +run_install +second=$INSTALL_STATUS +[ "$second" -eq 0 ] || show_install_log + +suite "second run" +assert_eq "first run exits 0" "0" "$first" +assert_eq "second run exits 0" "0" "$second" + +suite "links survive a re-run" +assert_core_links +assert_ignored_not_linked +assert_every_config_linked +assert_no_dangling_links + +suite "no duplicate stash piled up" +assert_ok "at most one stash entry" \ + bash -c "test \$(cd '$DOT' && git stash list | wc -l) -le 1" + +suite "repo still clean" +assert_ok "git status clean" \ + bash -c "cd '$DOT' && test -z \"\$(git status --porcelain)\"" diff --git a/tests/container/scenarios/piped.sh b/tests/container/scenarios/piped.sh new file mode 100644 index 0000000..0d3c3d9 --- /dev/null +++ b/tests/container/scenarios/piped.sh @@ -0,0 +1,23 @@ +# The documented bootstrap: `curl -fsSL .../install | bash`. +# Two things differ from a normal run: $0 is not a real path, and the cwd is +# wherever the user happened to be standing. +source "$DOT/tests/container/scenarios/_common.sh" + +( cd / && bash < "$DOT/install" ) > "$INSTALL_LOG" 2>&1 +INSTALL_STATUS=$? +echo " (piped install exited $INSTALL_STATUS)" +[ "$INSTALL_STATUS" -eq 0 ] || show_install_log + +suite "piped bootstrap (README workflow)" +assert_eq "install exits 0 when piped to bash" "0" "$INSTALL_STATUS" +assert_no_grep "no 'not found' errors" "not found|No such file" "$INSTALL_LOG" + +suite "configs linked despite the unknown cwd" +assert_core_links +assert_ignored_not_linked +assert_every_config_linked +assert_no_dangling_links + +suite "installers still ran" +assert_log_has "install .*ripgrep" +assert_log_has "chsh -s .*zsh" diff --git a/tests/lib/harness.sh b/tests/lib/harness.sh new file mode 100755 index 0000000..2b1be34 --- /dev/null +++ b/tests/lib/harness.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Tiny zero-dependency test harness. Source this, then call the assert_* helpers. +# Every assert prints a PASS/FAIL line and bumps the counters. Call `summary` at +# the end and exit with its status. + +TESTS_RUN=0 +TESTS_FAILED=0 +CURRENT_SUITE='' + +if [ -t 1 ]; then + C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_OFF=$'\033[0m' +else + C_RED=''; C_GREEN=''; C_DIM=''; C_BOLD=''; C_OFF='' +fi + +function suite() { + CURRENT_SUITE="$1" + printf '\n%s== %s ==%s\n' "$C_BOLD" "$1" "$C_OFF" +} + +function pass() { + TESTS_RUN=$((TESTS_RUN + 1)) + printf ' %sPASS%s %s\n' "$C_GREEN" "$C_OFF" "$1" +} + +function fail() { + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf ' %sFAIL%s %s\n' "$C_RED" "$C_OFF" "$1" + if [ -n "${2:-}" ]; then + printf ' %s%s%s\n' "$C_DIM" "$2" "$C_OFF" + fi +} + +# assert_ok +function assert_ok() { + local desc="$1"; shift + local out + if out=$("$@" 2>&1); then + pass "$desc" + else + fail "$desc" "exit $? from: $* :: ${out:0:400}" + fi +} + +# assert_fails +function assert_fails() { + local desc="$1"; shift + local out + if out=$("$@" 2>&1); then + fail "$desc" "expected failure but it succeeded: $*" + else + pass "$desc" + fi +} + +# assert_eq +function assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + pass "$desc" + else + fail "$desc" "expected [$expected] got [$actual]" + fi +} + +# assert_symlink [target-substring] +function assert_symlink() { + local path="$1" want="${2:-}" + if [ ! -L "$path" ]; then + fail "$path is a symlink" "$( [ -e "$path" ] && echo 'exists but is a regular file/dir' || echo 'does not exist' )" + return + fi + local target + target=$(readlink "$path") + if [ -n "$want" ] && [[ "$target" != *"$want"* ]]; then + fail "$path -> *$want*" "points at $target" + return + fi + if [ ! -e "$path" ]; then + fail "$path is a live symlink" "dangling -> $target" + return + fi + pass "$path -> $target" +} + +# assert_exists +function assert_exists() { + if [ -e "$1" ]; then pass "$1 exists"; else fail "$1 exists" "missing"; fi +} + +# assert_missing +function assert_missing() { + if [ ! -e "$1" ]; then pass "$1 absent"; else fail "$1 absent" "unexpectedly present"; fi +} + +# assert_grep +function assert_grep() { + local desc="$1" pattern="$2" file="$3" + if [ ! -f "$file" ]; then + fail "$desc" "no such file: $file" + elif grep -qE "$pattern" "$file"; then + pass "$desc" + else + fail "$desc" "pattern /$pattern/ not found in $file" + fi +} + +# assert_no_grep +function assert_no_grep() { + local desc="$1" pattern="$2" file="$3" + if [ -f "$file" ] && grep -qE "$pattern" "$file"; then + fail "$desc" "pattern /$pattern/ unexpectedly found in $file" + else + pass "$desc" + fi +} + +function summary() { + printf '\n%s%d run, %d failed%s\n' "$C_BOLD" "$TESTS_RUN" "$TESTS_FAILED" "$C_OFF" + [ "$TESTS_FAILED" -eq 0 ] +} + +# assert_links_into +# Tolerates stow "folding": the link may be on a parent directory. +function assert_links_into() { + local path="$1" want="$2" real + if [ ! -e "$path" ]; then + fail "$path links into *$want*" "path does not exist" + return + fi + real=$(readlink -f "$path") + if [[ "$real" == *"$want"* ]]; then + pass "$path => $real" + else + fail "$path links into *$want*" "resolves to $real" + fi +} + +# assert_log_has -- greps $STUB_LOG +function assert_log_has() { + assert_grep "stub log records: $1" "$1" "${STUB_LOG:-/tmp/stub.log}" +} + +# assert_log_missing +function assert_log_missing() { + assert_no_grep "stub log does NOT record: $1" "$1" "${STUB_LOG:-/tmp/stub.log}" +} diff --git a/tests/run b/tests/run new file mode 100755 index 0000000..d4c0561 --- /dev/null +++ b/tests/run @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Test harness for the dotfiles installer. +# +# ./tests/run unit tests only (fast, no Docker) +# ./tests/run --container unit tests + every scenario in Docker +# ./tests/run --container fresh dirty-repo +# ./tests/run --container --base fedora:41 +# ./tests/run --container --platform linux/amd64 test the other CPU arch +# ./tests/run --container --real fresh actually hit the network (slow) +# ./tests/run --shell drop into the sandbox container +set -uo pipefail + +REPO=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +IMAGE_PREFIX=dotfiles-test +BASE=debian:bookworm-slim +PLATFORM='' +WANT_CONTAINER=0 +WANT_SHELL=0 +REAL=0 +SCENARIOS=() + +ALL_SCENARIOS=(fresh arch-alt idempotent dirty-repo existing-configs codespaces piped) + +while [ $# -gt 0 ]; do + case "$1" in + --container|-c) WANT_CONTAINER=1; shift ;; + --shell) WANT_CONTAINER=1; WANT_SHELL=1; shift ;; + --real) REAL=1; shift ;; + --base) BASE="$2"; shift 2 ;; + --platform) PLATFORM="$2"; shift 2 ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "unknown flag: $1" >&2; exit 2 ;; + *) SCENARIOS+=("$1"); shift ;; + esac +done + +if [ -t 1 ]; then B=$'\033[1m'; R=$'\033[31m'; G=$'\033[32m'; O=$'\033[0m' +else B=''; R=''; G=''; O=''; fi + +FAILED=0 + +# ---------------------------------------------------------------- unit tests +if [ "$WANT_SHELL" -eq 0 ]; then + for t in "$REPO"/tests/unit/*.test.sh; do + printf '\n%s######## %s ########%s\n' "$B" "$(basename "$t")" "$O" + bash "$t" || FAILED=1 + done +fi + +# ----------------------------------------------------------- container tests +if [ "$WANT_CONTAINER" -eq 1 ]; then + if ! command -v docker >/dev/null 2>&1; then + echo "${R}docker not found — cannot run container tests${O}" >&2 + exit 1 + fi + + TAG="$IMAGE_PREFIX:$(echo "$BASE${PLATFORM:+-$PLATFORM}" | tr ':/' '--')" + printf '\n%s######## building %s (base %s%s) ########%s\n' \ + "$B" "$TAG" "$BASE" "${PLATFORM:+, platform $PLATFORM}" "$O" + + BUILD=(docker build --build-arg "BASE=$BASE" -t "$TAG") + [ -n "$PLATFORM" ] && BUILD+=(--platform "$PLATFORM") + "${BUILD[@]}" "$REPO/tests/container" || exit 1 + + DOCKER_RUN=(docker run --rm -v "$REPO:/src:ro") + [ -n "$PLATFORM" ] && DOCKER_RUN+=(--platform "$PLATFORM") + [ "$REAL" -eq 1 ] && DOCKER_RUN+=(-e REAL_INSTALL=1) + [ "$REAL" -eq 0 ] && DOCKER_RUN+=(--network none) + + if [ "$WANT_SHELL" -eq 1 ]; then + exec "${DOCKER_RUN[@]}" --entrypoint /bin/bash -it "$TAG" + fi + + [ ${#SCENARIOS[@]} -eq 0 ] && SCENARIOS=("${ALL_SCENARIOS[@]}") + + for s in "${SCENARIOS[@]}"; do + printf '\n%s######## container scenario: %s ########%s\n' "$B" "$s" "$O" + "${DOCKER_RUN[@]}" "$TAG" "$s" || FAILED=1 + done +fi + +printf '\n%s' "$B" +if [ "$FAILED" -eq 0 ]; then printf '%sALL GREEN%s\n' "$G" "$O" +else printf '%sSOME TESTS FAILED%s\n' "$R" "$O"; fi +exit "$FAILED" diff --git a/tests/stubs/bin/_stub b/tests/stubs/bin/_stub new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/_stub @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/apt b/tests/stubs/bin/apt new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/apt @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/apt-get b/tests/stubs/bin/apt-get new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/apt-get @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/chsh b/tests/stubs/bin/chsh new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/chsh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/curl b/tests/stubs/bin/curl new file mode 100755 index 0000000..6f7705b --- /dev/null +++ b/tests/stubs/bin/curl @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Offline curl. Fakes the two shapes the installers use: +# 1. GitHub "latest release" API queries -> synthetic JSON the jq filters match +# 2. asset downloads with -o -> a plausible file at that path +# Anything else prints nothing, so `curl ... | bash` bootstrap installers no-op. +set -e +printf 'curl %s\n' "$*" >> "${STUB_LOG:-/tmp/stub.log}" + +out='' +url='' +while [ $# -gt 0 ]; do + case "$1" in + -o|--output) out="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done + +if [ -n "$out" ]; then + mkdir -p "$(dirname "$out")" + base=$(basename "$out") + case "$base" in + *.tar.gz) + inner="${base%.tar.gz}" + tmp=$(mktemp -d) + printf '#!/bin/sh\necho stub %s\n' "$inner" > "$tmp/$inner" + chmod +x "$tmp/$inner" + tar -czf "$out" -C "$tmp" "$inner" + rm -rf "$tmp" + ;; + *.zip) + : > "$out" + ;; + *) + printf '#!/bin/sh\necho stub %s\n' "${base%.*}" > "$out" + chmod +x "$out" + ;; + esac + exit 0 +fi + +# Serves each repo its own asset list, with every arch a real release carries. +# A filter that does not pin the architecture then matches more than one asset, +# and github_asset_url is expected to refuse rather than guess. +case "$url" in + *api.github.com/repos/*/releases*) + repo=$(printf '%s' "$url" | sed -n 's|.*/repos/\([^/]*/[^/]*\)/releases.*|\1|p') + case "$repo" in + dlvhdr/diffnav) + assets=' + {"name":"diffnav_Linux_x86_64.tar.gz","browser_download_url":"https://stub.invalid/diffnav_Linux_x86_64.tar.gz"}, + {"name":"diffnav_Linux_arm64.tar.gz","browser_download_url":"https://stub.invalid/diffnav_Linux_arm64.tar.gz"}, + {"name":"diffnav_Linux_i386.tar.gz","browser_download_url":"https://stub.invalid/diffnav_Linux_i386.tar.gz"}, + {"name":"diffnav_Darwin_x86_64.tar.gz","browser_download_url":"https://stub.invalid/diffnav_Darwin_x86_64.tar.gz"}, + {"name":"diffnav_Darwin_arm64.tar.gz","browser_download_url":"https://stub.invalid/diffnav_Darwin_arm64.tar.gz"}' + ;; + so-fancy/diff-so-fancy) + assets=' + {"name":"diff-so-fancy","browser_download_url":"https://stub.invalid/diff-so-fancy"}' + ;; + pkgforge-dev/ghostty-appimage) + assets=' + {"name":"Ghostty-0.0.0-x86_64.AppImage","browser_download_url":"https://stub.invalid/Ghostty-x86_64.AppImage"}, + {"name":"Ghostty-0.0.0-x86_64.AppImage.zsync","browser_download_url":"https://stub.invalid/Ghostty-x86_64.AppImage.zsync"}, + {"name":"Ghostty-0.0.0-aarch64.AppImage","browser_download_url":"https://stub.invalid/Ghostty-aarch64.AppImage"}, + {"name":"Ghostty-0.0.0-aarch64.AppImage.zsync","browser_download_url":"https://stub.invalid/Ghostty-aarch64.AppImage.zsync"}' + ;; + wezterm/wezterm) + assets=' + {"name":"WezTerm-0.0.0-Ubuntu20.04.AppImage","browser_download_url":"https://stub.invalid/WezTerm-Ubuntu20.04.AppImage"}, + {"name":"wezterm-0.0.0.Debian12.arm64.deb","browser_download_url":"https://stub.invalid/wezterm-Debian12.arm64.deb"}, + {"name":"wezterm-0.0.0.Ubuntu22.04.arm64.deb","browser_download_url":"https://stub.invalid/wezterm-Ubuntu22.04.arm64.deb"}, + {"name":"wezterm-0.0.0.Debian12.deb","browser_download_url":"https://stub.invalid/wezterm-Debian12.deb"}' + ;; + TheAssassin/AppImageLauncher) + assets=' + {"name":"appimagelauncher_0.0.0_x86_64.rpm","browser_download_url":"https://stub.invalid/ail_x86_64.rpm"}, + {"name":"appimagelauncher_0.0.0_aarch64.rpm","browser_download_url":"https://stub.invalid/ail_aarch64.rpm"}, + {"name":"appimagelauncher_0.0.0_amd64.deb","browser_download_url":"https://stub.invalid/ail_amd64.deb"}, + {"name":"appimagelauncher_0.0.0_arm64.deb","browser_download_url":"https://stub.invalid/ail_arm64.deb"}' + ;; + *) + assets='' + ;; + esac + printf '{"tag_name":"v0.0.0-stub","assets":[%s\n]}\n' "$assets" + ;; +esac +exit 0 diff --git a/tests/stubs/bin/dnf b/tests/stubs/bin/dnf new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/dnf @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/gpg b/tests/stubs/bin/gpg new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/gpg @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/open b/tests/stubs/bin/open new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/open @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/rpm b/tests/stubs/bin/rpm new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/rpm @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/snap b/tests/stubs/bin/snap new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/snap @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/sudo b/tests/stubs/bin/sudo new file mode 100755 index 0000000..4ceffcd --- /dev/null +++ b/tests/stubs/bin/sudo @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Records the privileged command but never runs it. Tests must not touch /etc. +printf 'sudo %s\n' "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/systemctl b/tests/stubs/bin/systemctl new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/systemctl @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/uname b/tests/stubs/bin/uname new file mode 100755 index 0000000..bb78958 --- /dev/null +++ b/tests/stubs/bin/uname @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Passthrough uname, except `-m` when FAKE_ARCH is set. Lets one container +# exercise the asset picking for the CPU architecture it is not running on, +# which is far cheaper than emulating a whole other platform. +REAL=/usr/bin/uname +[ -x "$REAL" ] || REAL=/bin/uname + +if [ -n "${FAKE_ARCH:-}" ]; then + case "$*" in + -m|--machine) echo "$FAKE_ARCH"; exit 0 ;; + esac +fi + +exec "$REAL" "$@" diff --git a/tests/stubs/bin/unzip b/tests/stubs/bin/unzip new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/unzip @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/wget b/tests/stubs/bin/wget new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/wget @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/stubs/bin/xdg-mime b/tests/stubs/bin/xdg-mime new file mode 100755 index 0000000..0ff96ab --- /dev/null +++ b/tests/stubs/bin/xdg-mime @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Generic recording stub: logs the call and succeeds without doing anything. +printf '%s %s\n' "$(basename "$0")" "$*" >> "${STUB_LOG:-/tmp/stub.log}" +exit 0 diff --git a/tests/unit/helpers.test.sh b/tests/unit/helpers.test.sh new file mode 100755 index 0000000..a0fe59c --- /dev/null +++ b/tests/unit/helpers.test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Unit tests for scripts/installers/helpers. No network, no package manager. +set -uo pipefail +REPO=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +source "$REPO/tests/lib/harness.sh" + +# helpers sets -e; turn it back off so a failing assert does not kill the run. +source "$REPO/scripts/installers/helpers" +set +e + +suite "map_get" +BINARY_PKG_ALIASES=("rg ripgrep" "nvim neovim" "delta git-delta") +assert_eq "aliased key returns package" "ripgrep" "$(map_get rg "${BINARY_PKG_ALIASES[@]}")" +assert_eq "second aliased key" "neovim" "$(map_get nvim "${BINARY_PKG_ALIASES[@]}")" +assert_eq "unknown key passes through" "fzf" "$(map_get fzf "${BINARY_PKG_ALIASES[@]}")" +assert_eq "distro override map wins" "fd-find" "$(map_get fd "fd fd-find")" + +suite "has_binary" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +printf '#!/bin/sh\nexit 0\n' > "$TMP/dotfiles-fake-tool" +chmod +x "$TMP/dotfiles-fake-tool" + +OLD_PATH="$PATH" +PATH="$TMP:$PATH" + +INSTALLERS_DIR="/nonexistent-dir" +assert_ok "finds a real binary on PATH" has_binary ls +assert_ok "finds the fake tool when dir not masked" has_binary dotfiles-fake-tool +assert_fails "does not find a nonexistent binary" has_binary dotfiles-definitely-not-here + +INSTALLERS_DIR="$TMP" +assert_fails "ignores binaries inside the installers dir" has_binary dotfiles-fake-tool +assert_ok "still finds binaries elsewhere on PATH" has_binary ls + +PATH="$OLD_PATH" + +suite "is_git_clean" +CLEAN=$(mktemp -d) +git -C "$CLEAN" init -q +git -C "$CLEAN" config user.email t@t.test +git -C "$CLEAN" config user.name test +echo hi > "$CLEAN/a" +git -C "$CLEAN" add a +git -C "$CLEAN" commit -qm init +assert_ok "clean repo reports clean" bash -c "cd '$CLEAN' && source '$REPO/scripts/installers/helpers' && is_git_clean" +echo changed > "$CLEAN/a" +assert_fails "modified file reports dirty" bash -c "cd '$CLEAN' && source '$REPO/scripts/installers/helpers' && is_git_clean" +git -C "$CLEAN" checkout -q -- a +echo new > "$CLEAN/untracked" +assert_fails "untracked file reports dirty" bash -c "cd '$CLEAN' && source '$REPO/scripts/installers/helpers' && is_git_clean" +rm -rf "$CLEAN" + +NOTREPO=$(mktemp -d) +assert_ok "non-repo counts as clean" bash -c "cd '$NOTREPO' && source '$REPO/scripts/installers/helpers' && is_git_clean" +rm -rf "$NOTREPO" + +suite "arch_for" +# arch_for reads `uname -m`; a shell function shadows the real binary. +function uname() { echo "$FAKE_ARCH"; } + +FAKE_ARCH=x86_64 +assert_eq "intel -> uname" "x86_64" "$(arch_for uname)" +assert_eq "intel -> go" "amd64" "$(arch_for go)" +assert_eq "intel -> mixed" "x86_64" "$(arch_for mixed)" + +FAKE_ARCH=aarch64 +assert_eq "linux arm -> uname" "aarch64" "$(arch_for uname)" +assert_eq "linux arm -> go" "arm64" "$(arch_for go)" +assert_eq "linux arm -> mixed" "arm64" "$(arch_for mixed)" + +FAKE_ARCH=arm64 +assert_eq "mac arm -> uname" "arm64" "$(arch_for uname)" +assert_eq "mac arm -> go" "arm64" "$(arch_for go)" +assert_eq "mac arm -> mixed" "arm64" "$(arch_for mixed)" + +FAKE_ARCH=armv7l +assert_eq "armv7 -> go" "arm" "$(arch_for go)" + +FAKE_ARCH=riscv64 +assert_eq "unknown arch passes through" "riscv64" "$(arch_for go)" + +assert_fails "an unknown flavour is an error" arch_for sideways + +unset -f uname + +suite "get_distro_id" +assert_ok "returns something" bash -c "source '$REPO/scripts/installers/helpers' && test -n \"\$(get_distro_id)\"" + +summary diff --git a/tests/unit/lint.test.sh b/tests/unit/lint.test.sh new file mode 100755 index 0000000..cdbe070 --- /dev/null +++ b/tests/unit/lint.test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Parse-checks every script in the repo. Catches typos without running anything. +set -uo pipefail +REPO=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +source "$REPO/tests/lib/harness.sh" + +suite "syntax: bash scripts" + +while IFS= read -r script; do + rel="${script#"$REPO"/}" + assert_ok "bash -n $rel" bash -n "$script" +done < <( + find "$REPO/install" "$REPO/scripts" "$REPO/tests" -type f \ + ! -name '*.zip' ! -name '*.md' ! -path '*/.git/*' -print 2>/dev/null \ + | while read -r f; do head -c 200 "$f" | grep -qE '^#!.*(bash|/sh)' && echo "$f"; done +) + +suite "syntax: zsh config" + +if command -v zsh >/dev/null 2>&1; then + assert_ok "zsh -n configs/.zshrc" zsh -n "$REPO/configs/.zshrc" + for f in vars aliases prompt; do + assert_ok "zsh -n configs/shell/$f" zsh -n "$REPO/configs/shell/$f" + done +else + echo " SKIP zsh not installed" +fi + +suite "installer scripts are executable" +# They are invoked directly so their #!/bin/bash shebang applies. Losing the +# exec bit would silently fall back to whatever shell the caller was using. +while IFS= read -r script; do + rel="${script#"$REPO"/}" + if [ -x "$script" ]; then pass "$rel is executable"; else fail "$rel is executable" "mode $(ls -l "$script" | cut -d' ' -f1)"; fi +done < <(find "$REPO/scripts/installers" -type f ! -name helpers) +assert_ok "install is executable" test -x "$REPO/install" +assert_ok "ghostty setup.sh is executable" test -x "$REPO/configs/.config/ghostty/setup.sh" + +suite "no hard-coded interpreter prefixes" +# `sh some-bash-script` silently drops every bashism in it (this broke Debian). +assert_no_grep "no 'sh \$...' invocations" \ + '(^|[^[:alnum:]_])sh +"?\$' <(cat "$REPO/install" "$REPO"/scripts/installers/* "$REPO"/scripts/installers/app/* 2>/dev/null) + +suite "binary lookups go through has_binary" +# One code path for "is it installed?", so the installers-dir masking and the +# missing-`which` workaround apply everywhere. +while IFS= read -r script; do + rel="${script#"$REPO"/}" + code=$(mktemp) + grep -vE '^[[:space:]]*#' "$script" > "$code" + assert_no_grep "$rel uses has_binary, not command -v" 'command -v' "$code" + assert_no_grep "$rel uses binary_path, not which" '\$\(which ' "$code" + rm -f "$code" +done < <(find "$REPO/scripts/installers" -type f ! -name helpers) + +suite "syntax: misc" +assert_ok "bash -n configs/.bash_profile" bash -n "$REPO/configs/.bash_profile" +if command -v jq >/dev/null 2>&1; then + while IFS= read -r j; do + assert_ok "valid json ${j#"$REPO"/}" jq -e . "$j" + done < <(find "$REPO/configs" -name '*.json' ! -path '*/nvim/*' -type f) +fi + +summary