Add shellcheck lint and bats tests

make lint runs shellcheck on every tracked bash script, make test runs
the bats suite in tests/, and make check runs both. The tests cover the
update orchestrator (hook isolation, failure reporting, pull and
free-space handling) and utils.sh (migrations, package install and
removal, services, initramfs build check), using stub commands so they
never touch the real system.

Fixes from the first lint run: 20-aur.sh stops if a cd fails rather
than building or deleting in the wrong directory, and
80-gnome-extensions.sh refuses to rm -rf with an empty path. The rest
are false positives, silenced in .shellcheckrc or per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 16:15:12 -06:00
co-authored by Claude Opus 5.5
parent 7e08a048ca
commit d9da8208dd
16 changed files with 464 additions and 12 deletions
+7
View File
@@ -0,0 +1,7 @@
# Hooks, configure items and config files are sourced from paths only known at
# run time
disable=SC1090
# mkdir -p -m only sets the mode of the last directory; every use here makes
# one directory in a parent that already exists
disable=SC2174
+14
View File
@@ -0,0 +1,14 @@
# Every tracked file (not symlink) that starts with a bash or sh shebang (\043 is "#", which
# make would otherwise read as the start of a comment)
SCRIPTS := $(shell git ls-files -s | awk '$$1 != 120000 { print $$4 }' | xargs awk 'FNR == 1 && /^#!.*[\/ ](ba)?sh/ { print FILENAME }')
.PHONY: check lint test
check: lint test
# Errors and warnings only, for now; drop --severity to see style notes too
lint:
shellcheck --severity=warning $(SCRIPTS)
test:
bats tests
+2 -2
View File
@@ -15,10 +15,10 @@ fi
# The native installer places the binary in ~/.local/bin # The native installer places the binary in ~/.local/bin
LINE='export PATH="$HOME/.local/bin:$PATH"' LINE='export PATH="$HOME/.local/bin:$PATH"'
if grep -qF "$LINE" ~/.bashrc; then if grep -qF "$LINE" ~/.bashrc; then
echo "~/.local/bin already on PATH in .bashrc" echo "\~/.local/bin already on PATH in .bashrc"
else else
echo "$LINE" >> ~/.bashrc echo "$LINE" >> ~/.bashrc
echo "~/.local/bin added to PATH in .bashrc" echo "\~/.local/bin added to PATH in .bashrc"
fi fi
# Desktop notifications via notify-send (libnotify) # Desktop notifications via notify-send (libnotify)
Vendored
+1 -1
View File
@@ -96,7 +96,7 @@ load_items() {
show_items() { show_items() {
local i mark local i mark
for i in "${!ITEMS[@]}"; do for i in "${!ITEMS[@]}"; do
if [ "${STATES[$i]}" = done ]; then if [ "${STATES[$i]}" = "done" ]; then
mark="\e[32mdone\e[0m " mark="\e[32mdone\e[0m "
else else
mark="\e[33mto do\e[0m" mark="\e[33mto do\e[0m"
+5 -2
View File
@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
# configure reads TITLE and DESCRIPTION
# shellcheck disable=SC2034
TITLE="Federated Computer Core" TITLE="Federated Computer Core"
# shellcheck disable=SC2034
DESCRIPTION="Superpanel, Nextcloud, email, files and Element" DESCRIPTION="Superpanel, Nextcloud, email, files and Element"
# Each Core is in cores/<domain>.conf, with what you entered last time, so # Each Core is in cores/<domain>.conf, with what you entered last time, so
@@ -309,7 +312,7 @@ step() {
# can't be reached. # can't be reached.
ask_password() { ask_password() {
local tries local tries
for tries in 1 2 3; do for _ in 1 2 3; do
read -rsp "Your Core password: " PASSWORD read -rsp "Your Core password: " PASSWORD
echo echo
if [ -n "$1" ]; then nextcloud_app_password "$1"; else nextcloud_sign_in; fi if [ -n "$1" ]; then nextcloud_app_password "$1"; else nextcloud_sign_in; fi
@@ -381,7 +384,7 @@ change_password() {
local tries reached=1 local tries reached=1
load_core "$1" load_core "$1"
STATUS=0 STATUS=0
for tries in 1 2 3; do for _ in 1 2 3; do
read -rsp "Your new Core password: " PASSWORD read -rsp "Your new Core password: " PASSWORD
echo echo
nextcloud_sign_in nextcloud_sign_in
+3
View File
@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
# configure reads TITLE and DESCRIPTION
# shellcheck disable=SC2034
TITLE="Tailscale" TITLE="Tailscale"
# shellcheck disable=SC2034
DESCRIPTION="Join your Core's Tailscale network" DESCRIPTION="Join your Core's Tailscale network"
CORE_CONF="$HOME/.config/coredesktop/core.conf" CORE_CONF="$HOME/.config/coredesktop/core.conf"
+5 -2
View File
@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
# configure reads TITLE and DESCRIPTION
# shellcheck disable=SC2034
TITLE="Backup" TITLE="Backup"
# shellcheck disable=SC2034
DESCRIPTION="Back up your home folder daily to your Nextcloud files" DESCRIPTION="Back up your home folder daily to your Nextcloud files"
CORE_CONF="$HOME/.config/coredesktop/core.conf" CORE_CONF="$HOME/.config/coredesktop/core.conf"
@@ -33,7 +36,7 @@ nextcloud_login() {
local tries local tries
[ -n "$(backup_secret nextcloud "$WEBDAV")" ] \ [ -n "$(backup_secret nextcloud "$WEBDAV")" ] \
&& rclone lsf --max-depth 1 ":webdav:" > /dev/null 2>&1 && return 0 && rclone lsf --max-depth 1 ":webdav:" > /dev/null 2>&1 && return 0
for tries in 1 2 3; do for _ in 1 2 3; do
read -rsp "Your Core password: " PASSWORD read -rsp "Your Core password: " PASSWORD
echo echo
nextcloud_app_password "CoreDesktop backup on $HOSTNAME" nextcloud_app_password "CoreDesktop backup on $HOSTNAME"
@@ -161,7 +164,7 @@ run_configure() {
echo "There's already a backup in $BACKUP_DIR in your Nextcloud files." echo "There's already a backup in $BACKUP_DIR in your Nextcloud files."
read -rsp "Its passphrase: " pass read -rsp "Its passphrase: " pass
echo echo
RESTIC_PASSWORD_COMMAND= RESTIC_PASSWORD="$pass" restic cat config > /dev/null \ RESTIC_PASSWORD_COMMAND='' RESTIC_PASSWORD="$pass" restic cat config > /dev/null \
|| { echo "restic couldn't open it with that passphrase."; return 1; } || { echo "restic couldn't open it with that passphrase."; return 1; }
save_secret passphrase "$WEBDAV/$BACKUP_DIR" "$pass" || return 1 save_secret passphrase "$WEBDAV/$BACKUP_DIR" "$pass" || return 1
fi fi
+3
View File
@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
# configure reads TITLE and DESCRIPTION
# shellcheck disable=SC2034
TITLE="Claude Code" TITLE="Claude Code"
# shellcheck disable=SC2034
DESCRIPTION="Sign in to Anthropic" DESCRIPTION="Sign in to Anthropic"
is_installed() { is_installed() {
+3
View File
@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
# configure reads TITLE and DESCRIPTION
# shellcheck disable=SC2034
TITLE="opencode" TITLE="opencode"
# shellcheck disable=SC2034
DESCRIPTION="Sign in to AI providers" DESCRIPTION="Sign in to AI providers"
is_installed() { is_installed() {
+3
View File
@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
# configure reads TITLE and DESCRIPTION
# shellcheck disable=SC2034
TITLE="Hermes Agent" TITLE="Hermes Agent"
# shellcheck disable=SC2034
DESCRIPTION="Choose a model provider and tools" DESCRIPTION="Choose a model provider and tools"
is_installed() { is_installed() {
+1 -1
View File
@@ -27,7 +27,7 @@ source "$COREDESKTOP/system/backup-env.sh" || exit 1
[ -n "$RESTIC_PASSWORD_COMMAND" ] || fail "The backup's passphrase isn't in your keyring. Run: configure backup" [ -n "$RESTIC_PASSWORD_COMMAND" ] || fail "The backup's passphrase isn't in your keyring. Run: configure backup"
# Run at login, perhaps before the network is up; give it a few minutes # Run at login, perhaps before the network is up; give it a few minutes
for try in {1..10}; do for _ in {1..10}; do
curl -sf --max-time 10 -o /dev/null "${BACKUP_WEBDAV%%/remote.php/*}/status.php" && break curl -sf --max-time 10 -o /dev/null "${BACKUP_WEBDAV%%/remote.php/*}/status.php" && break
sleep 30 sleep 30
done done
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Shared setup for the bats tests. Each test gets its own HOME and a bin dir at
# the front of PATH where it can put fake versions of pacman, yay, sudo, etc.
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
common_setup() {
export HOME="$BATS_TEST_TMPDIR/home"
export STUB_BIN="$BATS_TEST_TMPDIR/bin"
export STUB_LOG="$BATS_TEST_TMPDIR/stub.log"
mkdir -p "$HOME" "$STUB_BIN"
: > "$STUB_LOG"
export PATH="$STUB_BIN:$PATH"
unset COREDESKTOP
}
# A test file that needs more setup defines its own setup that calls this first
setup() {
common_setup
}
# stub NAME [BODY]: put a fake NAME on PATH that appends "NAME args..." to
# $STUB_LOG and then runs BODY, a bash snippet that sees the args as "$@"
stub() {
local name="$1" body="${2:-}"
cat > "$STUB_BIN/$name" << EOF
#!/bin/bash
echo "$name \$*" >> "\$STUB_LOG"
$body
EOF
chmod +x "$STUB_BIN/$name"
}
# called NAME ARGS...: true if the stub log has exactly the line "NAME ARGS..."
called() {
grep -qxF "$*" "$STUB_LOG"
}
# not_called NAME: true if the stub NAME never ran
not_called() {
! grep -q "^$1 " "$STUB_LOG"
}
# uncalled NAME ARGS...: the opposite of called. (A bare "! called ..." would
# not fail the test: bats, like set -e, ignores the status of a ! command.)
uncalled() {
! called "$@"
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bats
load test_helper
# Each test runs the real update script from a fake CoreDesktop checkout in the
# test's HOME, with fake hooks in updates.d/ that log to $RAN when they run.
setup() {
# A checkout with nothing but the update script and the files it installs
common_setup
CD="$HOME/.local/share/coredesktop"
mkdir -p "$CD/updates.d" "$HOME/.local/bin"
cp "$REPO/update" "$CD/update"
for script in refresh sibling version configure audit; do
echo '#!/bin/bash' > "$CD/$script"
done
export RAN="$BATS_TEST_TMPDIR/ran"
unset UPDATE_REEXECED UPDATE_PULL_FAILED UPDATE_FORCE
stub clear
stub git 'exit "${GIT_STATUS:-0}"'
stub sudo 'exit "${SUDO_STATUS:-0}"'
stub df 'printf "Avail\n%s\n" "${DF_AVAIL:-104857600}"'
# Let go of bats' output pipes so the sudo keepalive's leftover sleep
# doesn't keep the test waiting
stub sleep 'exec > /dev/null 2>&1 3>&-; exec /usr/bin/sleep "$@"'
}
# hook NAME BODY: add updates.d/NAME.sh with BODY as its run_update
hook() {
printf '#!/bin/bash\nrun_update() {\n%s\n}\n' "$2" > "$CD/updates.d/$1.sh"
}
ran() {
[ "$(cat "$RAN" 2> /dev/null)" = "$(printf '%s\n' "$@")" ]
}
@test "update pulls, installs its scripts, and runs every hook in order" {
hook 20-b 'echo b >> "$RAN"'
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
[[ "$output" == *"Done."* ]]
called git -C "$CD" pull
ran a b
for script in update refresh sibling version configure audit; do
[ -x "$HOME/.local/bin/$script" ]
done
}
@test "update pulls only once, before re-running its new copy" {
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
[ "$(grep -c '^git ' "$STUB_LOG")" -eq 1 ]
ran a
}
@test "a failing hook doesn't stop the hooks after it" {
hook 10-a 'echo a >> "$RAN"'
hook 20-b 'echo b >> "$RAN"; return 1'
hook 30-c 'echo c >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"Done, with errors in: 20-b.sh"* ]]
ran a b c
}
@test "a hook that exits doesn't end the update" {
hook 10-a 'echo a >> "$RAN"; exit 0'
hook 20-b 'echo b >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
ran a b
}
@test "a hook using set -e fails at its first failing command" {
hook 10-a 'set -e; echo a >> "$RAN"; false; echo after >> "$RAN"'
hook 20-b 'echo b >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"errors in: 10-a.sh"* ]]
ran a b
}
@test "a hook's is_installed and functions don't leak into the next hook" {
printf '%s\n' '#!/bin/bash' 'is_installed() { false; }' \
'run_update() { echo a >> "$RAN"; }' > "$CD/updates.d/10-a.sh"
# 10-a's is_installed must not stop 20-b, and its run_update must not run
# again for 30-c, which has none
printf '%s\n' '#!/bin/bash' 'run_update() { echo b >> "$RAN"; }' \
> "$CD/updates.d/20-b.sh"
printf '%s\n' '#!/bin/bash' 'true' > "$CD/updates.d/30-c.sh"
run "$CD/update"
[ "$status" -eq 0 ]
ran b
}
@test "update runs the hooks on disk when git pull fails, and reports it" {
export GIT_STATUS=1
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"could not pull"* ]]
[[ "$output" == *"errors in: git pull"* ]]
ran a
}
@test "update stops before any hook when / is low on space" {
export DF_AVAIL=1048576
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[[ "$output" == *"needs at least 10 GiB"* ]]
[ ! -e "$RAN" ]
}
@test "UPDATE_FORCE skips the free space check" {
export DF_AVAIL=1048576 UPDATE_FORCE=1
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 0 ]
ran a
}
@test "update stops when sudo fails" {
export SUDO_STATUS=1
hook 10-a 'echo a >> "$RAN"'
run "$CD/update"
[ "$status" -eq 1 ]
[ ! -e "$RAN" ]
}
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env bats
load test_helper
# --- run_migrations ---------------------------------------------------------
# migration NAME [EXIT]: add migrations/NAME.sh that logs its name and exits EXIT
migration() {
mkdir -p "$COREDESKTOP/migrations"
printf '#!/bin/bash\necho %s >> "%s"\nexit %s\n' "$1" "$RAN" "${2:-0}" \
> "$COREDESKTOP/migrations/$1.sh"
}
setup_migrations() {
export COREDESKTOP="$BATS_TEST_TMPDIR/coredesktop"
RAN="$BATS_TEST_TMPDIR/ran"
STATE="$HOME/.local/state/coredesktop/migrations"
source "$REPO/utils.sh"
}
@test "run_migrations runs each migration once, oldest first" {
setup_migrations
migration 1792592710
migration 1792592700
migration 1792592720
run run_migrations
[ "$status" -eq 0 ]
[ "$(cat "$RAN")" = "$(printf '1792592700\n1792592710\n1792592720')" ]
[ -f "$STATE/1792592700.sh" ]
[ -f "$STATE/1792592720.sh" ]
run run_migrations
[ "$status" -eq 0 ]
[ "$(wc -l < "$RAN")" -eq 3 ]
}
@test "run_migrations stops at a failure and retries it next time" {
setup_migrations
migration 1792592700
migration 1792592710 1
migration 1792592720
run run_migrations
[ "$status" -eq 1 ]
[ "$(cat "$RAN")" = "$(printf '1792592700\n1792592710')" ]
[ -f "$STATE/1792592700.sh" ]
[ ! -f "$STATE/1792592710.sh" ]
[ ! -f "$STATE/1792592720.sh" ]
migration 1792592710
run run_migrations
[ "$status" -eq 0 ]
[ "$(tail -n 2 "$RAN")" = "$(printf '1792592710\n1792592720')" ]
}
@test "run_migrations does nothing without a migrations dir" {
setup_migrations
run run_migrations
[ "$status" -eq 0 ]
[ ! -e "$RAN" ]
}
@test "migrations are named by timestamp so they sort in the order written" {
for file in "$REPO"/migrations/*; do
[[ "$(basename "$file")" =~ ^[0-9]{10}\.sh$ ]] || {
echo "bad migration name: $file"
return 1
}
done
}
# --- install_packages / remove_packages -------------------------------------
# A fake pacman that knows about the packages in $INSTALLED, of which those in
# $AS_DEPS were installed as dependencies
stub_pacman() {
stub pacman '
case "$1" in
-Qi) [[ " $INSTALLED " == *" $2 "* ]] ;;
-Qg) exit 1 ;;
-Qdq) shift; for p; do [[ " $AS_DEPS " == *" $p "* ]] && echo "$p"; done; exit 0 ;;
esac'
stub sudo
stub yay
}
@test "install_packages installs only the missing packages" {
export INSTALLED="git vim" AS_DEPS=""
stub_pacman
source "$REPO/utils.sh"
run install_packages git tmux vim neovim
[ "$status" -eq 0 ]
called yay -S --noconfirm --answerdiff=None --answeredit=None --answerclean=None tmux neovim
not_called sudo
}
@test "install_packages does nothing when everything is installed" {
export INSTALLED="git vim" AS_DEPS=""
stub_pacman
source "$REPO/utils.sh"
run install_packages git vim
[ "$status" -eq 0 ]
not_called yay
}
@test "install_packages marks packages installed as dependencies explicit" {
export INSTALLED="git python" AS_DEPS="python"
stub_pacman
source "$REPO/utils.sh"
run install_packages git python
[ "$status" -eq 0 ]
called sudo pacman -D --asexplicit python
not_called yay
}
@test "remove_packages removes only exact matches, not providers" {
# herdr-bin provides herdr, so pacman -Qq herdr prints herdr-bin
stub pacman '
[ "$1" = -Qq ] || exit 1
case "$2" in
herdr) echo herdr-bin ;;
vim) echo vim ;;
*) exit 1 ;;
esac'
stub sudo
source "$REPO/utils.sh"
run remove_packages herdr vim missing
[ "$status" -eq 0 ]
called sudo pacman -Rns --noconfirm vim
}
@test "remove_packages does nothing when nothing is installed" {
stub pacman 'exit 1'
stub sudo
source "$REPO/utils.sh"
run remove_packages vim
[ "$status" -eq 0 ]
not_called sudo
}
# --- enable_services --------------------------------------------------------
@test "enable_services enables disabled services and skips the rest" {
stub systemctl '[ "$1" = is-enabled ] && [ "$2" = sshd ]'
stub sudo
mkdir -p "$HOME/.config/coredesktop"
echo "system cups" > "$HOME/.config/coredesktop/services-off"
source "$REPO/utils.sh"
run enable_services --now sshd cups bluetooth
[ "$status" -eq 0 ]
called sudo systemctl daemon-reload
called sudo systemctl enable --now bluetooth
uncalled sudo systemctl enable --now sshd
uncalled sudo systemctl enable --now cups
[[ "$output" == *"cups is turned off"* ]]
}
@test "enable_services without --now only enables" {
stub systemctl 'exit 1'
stub sudo
source "$REPO/utils.sh"
run enable_services bluetooth
[ "$status" -eq 0 ]
called sudo systemctl enable bluetooth
uncalled sudo systemctl daemon-reload
}
# --- initramfs_build_failed -------------------------------------------------
pacman_log() {
mkdir -p "$HOME/.local/state/coredesktop"
printf '%s\n' "$@" > "$HOME/.local/state/coredesktop/pacman-update.log"
source "$REPO/utils.sh"
}
@test "initramfs_build_failed is false without a log" {
source "$REPO/utils.sh"
run initramfs_build_failed
[ "$status" -ne 0 ]
}
@test "initramfs_build_failed is false when no initramfs was built" {
pacman_log "upgrading vim..."
run initramfs_build_failed
[ "$status" -ne 0 ]
}
@test "initramfs_build_failed is false when every image built" {
pacman_log "(3/4) Updating linux initcpios..." \
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'default'" \
"==> Image generation successful" \
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'fallback'" \
"==> Initcpio image generation successful"
run initramfs_build_failed
[ "$status" -ne 0 ]
}
@test "initramfs_build_failed is true when mkinitcpio reports an error" {
pacman_log "(3/4) Updating linux initcpios..." \
"==> ERROR: module not found" \
"==> Image generation successful"
run initramfs_build_failed
[ "$status" -eq 0 ]
}
@test "initramfs_build_failed is true when an image didn't build" {
pacman_log "(3/4) Updating linux initcpios..." \
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'default'" \
"==> Image generation successful" \
"==> Building image from preset: /etc/mkinitcpio.d/linux.preset: 'fallback'"
run initramfs_build_failed
[ "$status" -eq 0 ]
}
+3 -3
View File
@@ -10,14 +10,14 @@ YAY_FLAGS="--noconfirm --answerdiff=None --answeredit=None --answerclean=None"
if ! yay --version &>/dev/null; then if ! yay --version &>/dev/null; then
echo "yay is broken (likely a libalpm bump). Rebuilding yay..." echo "yay is broken (likely a libalpm bump). Rebuilding yay..."
sudo pacman -R --noconfirm yay sudo pacman -R --noconfirm yay
cd /tmp cd /tmp || exit 1
rm -rf yay rm -rf yay
git clone https://aur.archlinux.org/yay.git git clone https://aur.archlinux.org/yay.git
cd yay cd yay || exit 1
makepkg -si --noconfirm makepkg -si --noconfirm
cd .. cd ..
rm -rf yay rm -rf yay
cd cd || exit 1
fi fi
yay -Sua $YAY_FLAGS yay -Sua $YAY_FLAGS
+1 -1
View File
@@ -50,7 +50,7 @@ reinstall_incompatible_extensions() {
rm -rf "$backup" rm -rf "$backup"
else else
echo "Reinstall of $uuid failed; restoring the previous version." echo "Reinstall of $uuid failed; restoring the previous version."
rm -rf "$ext_root/$uuid" rm -rf "${ext_root:?}/${uuid:?}"
mv "$backup/$uuid" "$ext_root/$uuid" mv "$backup/$uuid" "$ext_root/$uuid"
rmdir "$backup" rmdir "$backup"
fi fi