Skip to content

Instantly share code, notes, and snippets.

@lbussy
Last active August 23, 2026 15:00
Show Gist options
  • Select an option

  • Save lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b to your computer and use it in GitHub Desktop.

Select an option

Save lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b to your computer and use it in GitHub Desktop.
Debian, Ubuntu, and Raspberry Pi OS Setup

Debian, Ubuntu, and Raspberry Pi OS Setup

setup_debian_family.sh installs command-line tools, Bash aliases, and helpers on Debian, Ubuntu, and Raspberry Pi OS/Raspbian.

The canonical installation is piped through sudo bash. That is explicit permission for the default system changes, so no second confirmation is requested. The validated SUDO_USER account—not root—receives user files.

Default behavior

By default, the installer:

  • refreshes APT metadata and installs ca-certificates, colordiff, curl, git, gh, htop, needrestart, and sudo;
  • installs the supplied Bash aliases as ~/.bash_aliases;
  • preserves an existing aliases file as a timestamped backup;
  • creates ~/.personal_aliases only when absent;
  • installs la, ll, and whitespace_clean in /usr/local/bin; and
  • retains the distribution-provided needrestart policy.

It does not perform a whole-system upgrade, remove packages, clean APT caches, or enable passwordless sudo unless selected.

Install

Default

curl -fsSL https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh | sudo bash

Minimal installations that have wget but not curl can bootstrap with:

wget -qO- https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh | sudo bash

The installer installs curl before its first internal download.

Passwordless sudo

curl -fsSL https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh \
  | sudo env ENABLE_PASSWORDLESS_SUDO=true bash

With wget:

wget -qO- https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh \
  | sudo env ENABLE_PASSWORDLESS_SUDO=true bash

This grants the invoking account unrestricted passwordless root access:

<user> ALL=(ALL:ALL) NOPASSWD: ALL

The rule is disabled by default, validated with visudo, installed with root ownership and mode 0440, and checked as part of the complete sudoers configuration. The installer refuses to create it for root. Current Raspberry Pi OS releases may disable passwordless sudo as a security protection; selecting this option deliberately reverses that protection.

Whole-system maintenance

curl -fsSL https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh \
  | sudo env ENABLE_SYSTEM_MAINTENANCE=true bash

The same options can be used with the wget -qO- ... | sudo env ... bash bootstrap form.

This repairs broken package state, performs apt-get full-upgrade, removes obsolete packages with autoremove --purge, and cleans the APT cache. It can install a kernel, restart services, remove packages, and materially change the host.

Both options

curl -fsSL https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh \
  | sudo env \
      ENABLE_SYSTEM_MAINTENANCE=true \
      ENABLE_PASSWORDLESS_SUDO=true \
      bash

Dry run

curl -fsSL https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh \
  | sudo env DRY_RUN=true bash

Preview every optional path:

curl -fsSL https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh \
  | sudo env \
      DRY_RUN=true \
      ENABLE_SYSTEM_MAINTENANCE=true \
      ENABLE_PASSWORDLESS_SUDO=true \
      bash

Dry run performs detection and prints intended commands. It does not download payloads, change APT state, install files, create sudoers rules, or leave temporary files.

Console output

The installer prints compact option notices before the first checklist step. Informational findings discovered during installation are held until all checklist operations finish. The installer uses status tags for each command step. On a capable TTY, running, successful, warning, and failed states are colored; redirected output, TERM=dumb, terminals with fewer than eight colors, and environments defining NO_COLOR receive plain text. Successful command output is suppressed to keep the installation readable. If a command fails, its captured output is printed with the failing status before the installer exits nonzero.

Configuration

Boolean options accept exactly true or false.

Variable Default Purpose
DRY_RUN false Print intended actions without persistent changes.
ENABLE_PASSWORDLESS_SUDO false Grant unrestricted passwordless sudo to the invoking account.
ENABLE_SYSTEM_MAINTENANCE false Run full upgrade, autoremove, and cache cleanup.
BASH_ALIASES_URL This gist's raw my_aliases.sh Select the aliases payload.
WHITESPACE_CLEAN_URL Maintained raw helper gist Select the whitespace helper payload.

Pass configuration after sudo env. Variables placed only before curl do not configure the receiving Bash process.

Account and privilege handling

The canonical pipeline runs Bash as root through sudo. The installer requires a non-root SUDO_USER, verifies that account's numeric UID against SUDO_UID, resolves its real home and primary group, confirms home ownership, and installs user files with that ownership. Aliases therefore go to the invoking user's home, not /root.

A direct interactive non-root invocation is supported after sudo -v and confirmation. A noninteractive invocation that is not already root is rejected.

An existing ~/.bash_aliases is copied to ~/.bash_aliases.backup.<UTC timestamp> before replacement. Existing ~/.personal_aliases content is preserved. The installer cannot alter the parent shell; open a new login shell or run:

source ~/.bash_aliases

Remote-content trust

The convenient commands intentionally use mutable raw gist URLs. HTTPS protects transport but does not bind the content to a previously reviewed revision. The aliases are sourced shell code and whitespace_clean is installed as an executable.

Download and inspect first:

curl -fsSL https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/setup_debian_family.sh -o setup_debian_family.sh
less setup_debian_family.sh
sudo bash setup_debian_family.sh

The installation links intentionally remain revision-free and follow the Gist's current content.

Distribution and failure behavior

The installer reads ID and ID_LIKE from /etc/os-release, reports the distribution and version, and accepts Debian, Ubuntu, Raspberry Pi OS/Raspbian, and Debian-derived systems. It leaves each distribution's needrestart policy intact.

Required APT, download, validation, and installation failures stop with a nonzero status. Option states are reported before the checklist begins. Temporary files use a private random directory beneath a validated, root-owned, sticky /tmp; inherited TMPDIR is not trusted. The directory is cleaned on exit.


© 2024–2026 Lee Bussy — Distributed under the MIT License.

Bash Aliases and Helpers

my_aliases.sh is installed as ~/.bash_aliases by the setup installer.

General shortcuts

  • cd.. corrects the common typo.
  • grep enables match coloring; egrep and fgrep select extended and fixed-string matching.
  • cls clears the terminal.
  • ping sends three packets by default.

System and maintenance commands

  • reboot and shutdown broadcast a notice, wait one second, and invoke the corresponding command through sudo.
  • update repairs packages, refreshes APT, performs a full upgrade, removes obsolete packages, cleans the cache, and runs needrestart.
  • upgrade invokes update.
  • cleanup removes obsolete packages, cleans APT caches, and removes the current user's thumbnail cache.

These aliases make material system changes and may request a sudo password.

Installed helpers

  • la [path] lists all entries in a long, human-readable format.
  • ll [path] lists non-hidden entries in a long, human-readable format.
  • whitespace_clean removes trailing whitespace from supported source files.

Personal aliases

The installer creates ~/.personal_aliases only when absent. Existing content is preserved and sourced by ~/.bash_aliases.

source ~/.bash_aliases

© 2024–2026 Lee Bussy — Distributed under the MIT License.

#!/bin/env bash
# -----------------------------------------------------------------------------
# General Helpers and Shortcuts
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# @var cd..
# @brief Corrects a common typo when navigating directories.
# -----------------------------------------------------------------------------
alias cd..='cd ..'
# -----------------------------------------------------------------------------
# @var ll
# @brief Lists files in long format with human-readable sizes.
# -----------------------------------------------------------------------------
# Moved this to a function in path as ll
# alias ll='ls -lh'
# -----------------------------------------------------------------------------
# @var la
# @brief Lists all files, including hidden files, in long format with
# human-readable sizes.
# -----------------------------------------------------------------------------
# Moved this to a function in path as la
# alias la="ls -alh --color=always | awk '!/^l/ {print} /^l/ {sub(/ -> .*/, \"\"); print}'"
# -----------------------------------------------------------------------------
# @var grep
# @brief Highlights matches in grep output for better visibility.
# -----------------------------------------------------------------------------
alias grep='grep --color=auto'
# -----------------------------------------------------------------------------
# @var egrep
# @brief Alias for extended grep functionality.
# -----------------------------------------------------------------------------
alias egrep='grep -E'
# -----------------------------------------------------------------------------
# @var fgrep
# @brief Alias for fixed-string grep functionality.
# -----------------------------------------------------------------------------
alias fgrep='grep -F'
# -----------------------------------------------------------------------------
# @var cls
# @brief Clears the terminal screen.
# -----------------------------------------------------------------------------
alias cls='clear'
# -----------------------------------------------------------------------------
# @var clean_whitespace
# @brief Cleans excess whitespace from the end of lines (such as for source files)
# -----------------------------------------------------------------------------
# Removed in favor of /usr/local/bin/whitespace_clean
# -----------------------------------------------------------------------------
# System Commands
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# @var reboot
# @brief Reboots the system immediately with sudo privileges.
# -----------------------------------------------------------------------------
alias reboot='wall "Rebooting." 2>/dev/null && sleep 1 && sudo reboot now'
# -----------------------------------------------------------------------------
# @var shutdown
# @brief Shuts down the system immediately with sudo privileges.
# -----------------------------------------------------------------------------
alias shutdown='wall "Shutting down." && sleep 1 && sudo shutdown now -h'
# -----------------------------------------------------------------------------
# @var ping
# @brief Pings a host with three packets by default.
# -----------------------------------------------------------------------------
alias ping='ping -c 3'
# -----------------------------------------------------------------------------
# System Maintenance
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# @var update
# @brief Updates the system, upgrades packages, and performs cleanup with error handling.
# -----------------------------------------------------------------------------
alias update='echo "Running update..."; \
bash -e -c '\''trap "echo Error: Upgrade alias failed; exit 1" ERR; \
sudo apt --fix-broken install -y && \
sudo apt update && \
sudo apt full-upgrade -y && \
sudo apt autoremove --purge -y && \
sudo apt clean && \
sudo needrestart < /dev/tty'\'''
# -----------------------------------------------------------------------------
# @var upgrade
# @brief Alias for the `update` process.
# -----------------------------------------------------------------------------
alias upgrade='update'
# -----------------------------------------------------------------------------
# @var cleanup
# @brief Cleans up unneeded packages and clears system caches.
# -----------------------------------------------------------------------------
alias cleanup='echo "Cleaning apt system..."; sudo apt autoremove -y && \
sudo apt autoclean -y && rm -rf ~/.cache/thumbnails/*'
# -----------------------------------------------------------------------------
# Functions for Flexibility
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# @fn reload_aliases
# @brief Reloads aliases from the ~/.bash_aliases file.
# @param [optional] --debug Enables debugging output.
# -----------------------------------------------------------------------------
reload_aliases() {
local debug=false
if [[ $1 == "--debug" ]]; then
debug=true
fi
if [ -f ~/.bash_aliases ]; then
# shellcheck source=/dev/null
source ~/.bash_aliases
echo "Aliases reloaded!"
$debug && echo "DEBUG: Sourced ~/.bash_aliases"
else
echo "Error: ~/.bash_aliases not found!"
$debug && echo "DEBUG: Check if the file path is correct."
fi
}
# -----------------------------------------------------------------------------
# Source Personal Aliases
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# @brief Sources personal aliases from the ~/.personal_aliases file if it exists.
# -----------------------------------------------------------------------------
if [ -f ~/.personal_aliases ]; then
# shellcheck source=/dev/null
source ~/.personal_aliases
echo "Loaded personal aliases from ~/.personal_aliases"
fi
#!/usr/bin/env bash
set -Eeuo pipefail
readonly DEFAULT_ALIASES_URL="https://gist.githubusercontent.com/lbussy/23c05d8dc8c24d8d8edddf1d381f1c8b/raw/my_aliases.sh"
readonly DEFAULT_WHITESPACE_URL="https://gist.githubusercontent.com/lbussy/a0c00aa8b5f68d0aeb2e2c6dcd1676e8/raw/whitespace_clean"
DRY_RUN="${DRY_RUN:-false}"
ENABLE_PASSWORDLESS_SUDO="${ENABLE_PASSWORDLESS_SUDO:-false}"
ENABLE_SYSTEM_MAINTENANCE="${ENABLE_SYSTEM_MAINTENANCE:-false}"
BASH_ALIASES_URL="${BASH_ALIASES_URL:-$DEFAULT_ALIASES_URL}"
WHITESPACE_CLEAN_URL="${WHITESPACE_CLEAN_URL:-$DEFAULT_WHITESPACE_URL}"
OS_RELEASE_FILE="/etc/os-release"
TARGET_USER=""
TARGET_UID=""
TARGET_GROUP=""
TARGET_HOME=""
DISTRO_ID=""
DISTRO_NAME=""
DISTRO_VERSION=""
WORK_DIR=""
declare -a PRIV=()
declare -a POSTFLIGHT_MESSAGES=()
readonly -a BOOTSTRAP_COMMANDS=(bash chmod cp date env apt-get dpkg dpkg-query getent grep id install mktemp mv rm sed stat)
RESET=""
BOLD=""
FGRED=""
FGGRN=""
FGGLD=""
MOVE_UP=""
CLEAR_LINE=""
stream_supports_color() {
local fd="$1" colors
[[ -t "$fd" && -z "${NO_COLOR+x}" && "${TERM:-dumb}" != dumb ]] || return 1
command -v tput >/dev/null 2>&1 || return 1
colors="$(tput colors 2>/dev/null || printf 0)"
[[ "$colors" =~ ^[0-9]+$ && "$colors" -ge 8 ]]
}
init_terminal() {
if stream_supports_color 1 || stream_supports_color 2; then
RESET="$(tput sgr0 2>/dev/null || true)"
BOLD="$(tput bold 2>/dev/null || true)"
FGRED="$(tput setaf 1 2>/dev/null || true)"
FGGRN="$(tput setaf 2 2>/dev/null || true)"
FGGLD="$(tput setaf 3 2>/dev/null || true)"
MOVE_UP="$(tput cuu1 2>/dev/null || true)"
CLEAR_LINE="$(tput el 2>/dev/null || true)"
fi
}
tag() {
local fd="$1" color="$2" label="$3"
if stream_supports_color "$fd"; then
printf '%b%b[%s]%b' "$BOLD" "$color" "$label" "$RESET"
else
printf '[%s]' "$label"
fi
}
info() { tag 1 "$FGGRN" 'INFO '; printf ' %s\n' "$*"; }
defer_info() { POSTFLIGHT_MESSAGES+=("$*"); }
report_postflight() {
local message
for message in "$@"; do
info "$message"
done
}
warn() { { tag 2 "$FGGLD" 'WARN '; printf ' %s\n' "$*"; } >&2; }
die() { { tag 2 "$FGRED" 'ERROR'; printf ' %s\n' "$*"; } >&2; exit 1; }
parse_bool() {
local name="$1" value="$2"
case "$value" in
true|false) ;;
*) die "$name must be exactly 'true' or 'false' (received: $value)." ;;
esac
}
show_command() {
local arg
local rendered=()
for arg in "$@"; do
printf -v arg '%q' "$arg"
rendered+=("$arg")
done
printf '%s' "${rendered[*]}"
}
exec() {
local description="$1"
shift
if [[ "$DRY_RUN" == true ]]; then
tag 1 "$FGGLD" 'DRY '
printf ' %s: ' "$description"
show_command "$@"
printf '\n'
return 0
fi
local output status=0
output="$(mktemp "${WORK_DIR:-/tmp}/setup-command.XXXXXXXX")" ||
die "Unable to create command-output file."
tag 1 "$FGGLD" '-'
printf ' Running: %s.\n' "$description"
if "$@" >"$output" 2>&1; then
status=0
else
status=$?
fi
if stream_supports_color 1 && [[ -n "$MOVE_UP" && -n "$CLEAR_LINE" ]]; then
printf '%b%b' "$MOVE_UP" "$CLEAR_LINE"
fi
if ((status == 0)); then
tag 1 "$FGGRN" ''
printf ' Complete: %s.\n' "$description"
else
{ tag 2 "$FGRED" ''; printf ' Failed: %s (status %d).\n' "$description" "$status"; } >&2
if [[ -s "$output" ]]; then
printf '%s\n' '--- command output ---' >&2
sed 's/^/ /' "$output" >&2
printf '%s\n' '--- end command output ---' >&2
fi
fi
rm -f -- "$output"
return "$status"
}
exec_priv() {
local description="$1"
shift
exec "$description" "${PRIV[@]}" "$@"
}
cleanup() {
local status=$?
if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then
rm -rf -- "$WORK_DIR" || warn "Unable to remove temporary directory: $WORK_DIR"
fi
return "$status"
}
on_signal() {
local signal="$1"
warn "Interrupted by $signal."
case "$signal" in
HUP) exit 129 ;;
INT) exit 130 ;;
TERM) exit 143 ;;
esac
}
trap cleanup EXIT
trap 'on_signal HUP' HUP
trap 'on_signal INT' INT
trap 'on_signal TERM' TERM
require_commands() {
local command
local missing=()
# curl is installed below before the first in-script download. Keeping it
# out of this bootstrap list lets minimal systems start via wget.
for command in "${BOOTSTRAP_COMMANDS[@]}"; do
command -v "$command" >/dev/null 2>&1 || missing+=("$command")
done
(("${#missing[@]}" == 0)) || die "Missing required commands: ${missing[*]}"
}
detect_distribution() {
[[ -r "$OS_RELEASE_FILE" ]] || die "Cannot read $OS_RELEASE_FILE."
local ID="" ID_LIKE="" NAME="" PRETTY_NAME="" VERSION_ID=""
# os-release is an operating-system-owned shell-compatible data file.
# shellcheck disable=SC1090
. "$OS_RELEASE_FILE"
DISTRO_ID="${ID,,}"
DISTRO_NAME="${PRETTY_NAME:-${NAME:-$DISTRO_ID}}"
DISTRO_VERSION="${VERSION_ID:-unknown}"
case "$DISTRO_ID" in
debian|ubuntu|raspbian) ;;
*)
case " ${ID_LIKE,,} " in
*" debian "*) ;;
*) die "Unsupported distribution ID '$DISTRO_ID' (ID_LIKE='${ID_LIKE:-}')." ;;
esac
;;
esac
command -v apt-get >/dev/null 2>&1 || die "This Debian-family system lacks apt-get."
info "Detected $DISTRO_NAME (version $DISTRO_VERSION)."
}
validate_username() {
local user="$1"
[[ "$user" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || die "Unsafe or unsupported account name: '$user'."
[[ "$user" != root ]] || die "Refusing to install user configuration or NOPASSWD policy for root."
}
select_target_username() {
local effective_uid="$1" current_user="$2" sudo_user="$3"
if ((effective_uid == 0)); then
[[ -n "$sudo_user" ]] || return 1
printf '%s\n' "$sudo_user"
else
printf '%s\n' "$current_user"
fi
}
validate_sudo_identity() {
local target_uid="$1" sudo_uid="$2"
[[ "$sudo_uid" =~ ^[0-9]+$ && "$sudo_uid" != 0 && "$target_uid" == "$sudo_uid" ]]
}
resolve_target_user() {
local passwd_record current_user home_owner
current_user="$(id -un)" || die "Unable to determine the current account."
TARGET_USER="$(select_target_username "$EUID" "$current_user" "${SUDO_USER:-}")" ||
die "Run through sudo from the intended account; SUDO_USER is unavailable."
validate_username "$TARGET_USER"
passwd_record="$(getent passwd "$TARGET_USER")" || die "Account '$TARGET_USER' does not exist."
IFS=: read -r _ _ TARGET_UID _ _ TARGET_HOME _ <<<"$passwd_record"
[[ "$TARGET_UID" =~ ^[0-9]+$ ]] || die "Account '$TARGET_USER' has an invalid numeric UID."
if ((EUID == 0)); then
validate_sudo_identity "$TARGET_UID" "${SUDO_UID:-}" ||
die "SUDO_USER '$TARGET_USER' does not match SUDO_UID '${SUDO_UID:-unset}'."
fi
[[ -n "$TARGET_HOME" && "$TARGET_HOME" == /* && "$TARGET_HOME" != / ]] || die "Account '$TARGET_USER' has an unsafe home directory: '$TARGET_HOME'."
[[ -d "$TARGET_HOME" && ! -L "$TARGET_HOME" ]] || die "Home for '$TARGET_USER' must be an existing, non-symlink directory."
home_owner="$(stat -c %u -- "$TARGET_HOME")" || die "Unable to inspect ownership of '$TARGET_HOME'."
[[ "$home_owner" == "$TARGET_UID" ]] || die "Home '$TARGET_HOME' is not owned by '$TARGET_USER'."
TARGET_GROUP="$(id -gn "$TARGET_USER")" || die "Unable to determine the primary group for '$TARGET_USER'."
info "User configuration target: $TARGET_USER ($TARGET_HOME)."
}
configure_privilege() {
if ((EUID == 0)); then
PRIV=()
info "Running as root through sudo."
return 0
fi
command -v sudo >/dev/null 2>&1 || die "Run this installer with sudo, or install sudo first."
sudo -v || die "This installer requires sudo privileges."
PRIV=(sudo)
if [[ -t 0 ]]; then
local answer
read -r -p "This script will modify the system. Continue? [y/N]: " answer
[[ "${answer,,}" == y || "${answer,,}" == yes ]] || die "Cancelled."
else
die "Noninteractive execution must use the documented 'curl ... | sudo bash' form."
fi
}
create_work_dir() {
local temp_owner temp_mode
if [[ "$DRY_RUN" == true ]]; then
return 0
fi
[[ -d /tmp && ! -L /tmp ]] || die "/tmp must be a real directory."
temp_owner="$(stat -c %u -- /tmp)" || die "Unable to inspect /tmp ownership."
temp_mode="$(stat -c %a -- /tmp)" || die "Unable to inspect /tmp permissions."
[[ "$temp_owner" == 0 && "$temp_mode" =~ ^1[0-7]{3}$ ]] ||
die "/tmp must be root-owned and have the sticky bit set."
WORK_DIR="$(mktemp -d /tmp/setup-pi.XXXXXXXX)" || die "Unable to create a temporary directory."
chmod 700 "$WORK_DIR"
}
apt_install_dependencies() {
local packages=(ca-certificates colordiff curl git gh htop needrestart sudo)
exec_priv "Refresh APT package metadata" apt-get update
exec_priv "Install required packages" env DEBIAN_FRONTEND=noninteractive apt-get install -y "${packages[@]}"
}
report_preflight() {
if [[ "$DRY_RUN" == true ]]; then
info "Dry run enabled: no changes will be made."
else
info "Dry run disabled."
fi
if [[ "$ENABLE_SYSTEM_MAINTENANCE" == true ]]; then
warn "System maintenance enabled: upgrades allowed."
else
info "System maintenance disabled."
fi
if [[ "$ENABLE_PASSWORDLESS_SUDO" == true ]]; then
warn "Passwordless sudo enabled for $TARGET_USER."
else
info "Passwordless sudo disabled."
fi
}
run_system_maintenance() {
if [[ "$ENABLE_SYSTEM_MAINTENANCE" != true ]]; then
return 0
fi
exec_priv "Repair broken package state" env DEBIAN_FRONTEND=noninteractive apt-get --fix-broken install -y
exec_priv "Perform full system upgrade" env DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y
exec_priv "Remove obsolete packages" env DEBIAN_FRONTEND=noninteractive apt-get autoremove --purge -y
exec_priv "Clean APT package cache" apt-get clean
}
backup_existing_aliases() {
local destination="$1" backup stamp
[[ -e "$destination" || -L "$destination" ]] || return 0
[[ -f "$destination" && ! -L "$destination" ]] ||
die "Refusing to back up non-regular or symlinked aliases file: $destination"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup="${destination}.backup.${stamp}"
[[ ! -e "$backup" ]] || die "Backup destination already exists: $backup"
exec_priv "Back up existing .bash_aliases" install -o "$TARGET_USER" -g "$TARGET_GROUP" -m 0600 "$destination" "$backup"
}
install_aliases() {
local downloaded destination="$TARGET_HOME/.bash_aliases"
local personal="$TARGET_HOME/.personal_aliases"
if [[ "$DRY_RUN" == true ]]; then
defer_info "Aliases download planned."
defer_info "Alias backup and installation planned for $TARGET_USER."
return 0
fi
if [[ -e "$personal" || -L "$personal" ]]; then
[[ -f "$personal" && ! -L "$personal" ]] ||
die "Refusing non-regular or symlinked .personal_aliases."
fi
downloaded="$WORK_DIR/bash_aliases"
exec "Download Bash aliases" curl -fL --proto '=https' --tlsv1.2 "$BASH_ALIASES_URL" -o "$downloaded"
[[ -s "$downloaded" ]] || die "Downloaded aliases file is empty."
bash -n "$downloaded" || die "Downloaded aliases file is not valid Bash."
backup_existing_aliases "$destination"
exec_priv "Install .bash_aliases" install -o "$TARGET_USER" -g "$TARGET_GROUP" -m 0600 "$downloaded" "$destination"
if [[ ! -e "$personal" ]]; then
exec_priv "Create .personal_aliases" install -o "$TARGET_USER" -g "$TARGET_GROUP" -m 0600 /dev/null "$personal"
else
defer_info "Existing .personal_aliases preserved."
fi
}
write_helper() {
local options="$1" output="$2"
cat >"$output" <<EOF
#!/usr/bin/env bash
set -uo pipefail
target="\${1:-.}"
ls $options --color=always -- "\$target" 2>/dev/null | sed -E 's/ -> .*//'
EOF
bash -n "$output"
}
install_helpers() {
local helper
if [[ "$DRY_RUN" == true ]]; then
defer_info "Helper installation planned in /usr/local/bin."
return 0
fi
helper="$WORK_DIR/la"
write_helper "-hal" "$helper" || die "Unable to generate la helper."
exec_priv "Install la helper" install -o root -g root -m 0755 "$helper" /usr/local/bin/la
helper="$WORK_DIR/ll"
write_helper "-hl" "$helper" || die "Unable to generate ll helper."
exec_priv "Install ll helper" install -o root -g root -m 0755 "$helper" /usr/local/bin/ll
helper="$WORK_DIR/whitespace_clean"
exec "Download whitespace_clean" curl -fL --proto '=https' --tlsv1.2 "$WHITESPACE_CLEAN_URL" -o "$helper"
[[ -s "$helper" ]] || die "Downloaded whitespace_clean is empty."
bash -n "$helper" || die "Downloaded whitespace_clean is not valid Bash."
exec_priv "Install whitespace_clean" install -o root -g root -m 0755 "$helper" /usr/local/bin/whitespace_clean
}
sudoers_rule_exists() {
local escaped_user
escaped_user="$(printf '%s' "$TARGET_USER" | sed 's/[][\\.^$*+?{}|()]/\\&/g')"
"${PRIV[@]}" grep -ERqs "^[[:space:]]*${escaped_user}[[:space:]]+ALL[[:space:]]*=\\(ALL(:ALL)?\\)[[:space:]]+NOPASSWD:[[:space:]]*ALL[[:space:]]*$" /etc/sudoers /etc/sudoers.d 2>/dev/null
}
render_sudoers_rule() {
printf '%s ALL=(ALL:ALL) NOPASSWD: ALL\n' "$1"
}
install_passwordless_sudo() {
if [[ "$ENABLE_PASSWORDLESS_SUDO" != true ]]; then
return 0
fi
if [[ "$DRY_RUN" == true ]]; then
defer_info "Passwordless sudo installation planned for $TARGET_USER."
return 0
fi
command -v visudo >/dev/null 2>&1 || die "visudo is required when ENABLE_PASSWORDLESS_SUDO=true."
if sudoers_rule_exists; then
defer_info "An equivalent NOPASSWD rule already exists for $TARGET_USER."
return 0
fi
local candidate="$WORK_DIR/sudoers-candidate"
local destination="/etc/sudoers.d/010-${TARGET_USER}-nopasswd"
local stage backup=""
render_sudoers_rule "$TARGET_USER" >"$candidate"
chmod 0600 "$candidate"
visudo -cf "$candidate" >/dev/null || die "Generated sudoers rule failed validation."
if "${PRIV[@]}" test -e "$destination"; then
backup="$WORK_DIR/sudoers-existing"
"${PRIV[@]}" cp -p "$destination" "$backup" || die "Unable to back up existing sudoers rule."
fi
stage="$("${PRIV[@]}" mktemp "/etc/sudoers.d/.010-${TARGET_USER}-nopasswd.XXXXXXXX")" || die "Unable to stage sudoers rule."
if ! "${PRIV[@]}" install -o root -g root -m 0440 "$candidate" "$stage" ||
! "${PRIV[@]}" mv -f "$stage" "$destination" ||
! "${PRIV[@]}" visudo -cf /etc/sudoers >/dev/null; then
"${PRIV[@]}" rm -f -- "$stage" "$destination"
if [[ -n "$backup" ]]; then
"${PRIV[@]}" install -o root -g root -m 0440 "$backup" "$destination"
fi
die "Sudoers installation failed validation and was rolled back."
fi
defer_info "Passwordless sudo rule installed for $TARGET_USER."
}
report_needrestart_policy() {
case "$DISTRO_ID" in
raspbian) defer_info "Raspberry Pi OS needrestart policy retained." ;;
*) defer_info "Distribution needrestart policy retained." ;;
esac
}
main() {
init_terminal
parse_bool DRY_RUN "$DRY_RUN"
parse_bool ENABLE_PASSWORDLESS_SUDO "$ENABLE_PASSWORDLESS_SUDO"
parse_bool ENABLE_SYSTEM_MAINTENANCE "$ENABLE_SYSTEM_MAINTENANCE"
require_commands
detect_distribution
resolve_target_user
configure_privilege
create_work_dir
report_preflight
apt_install_dependencies
run_system_maintenance
install_aliases
install_helpers
install_passwordless_sudo
report_needrestart_policy
report_postflight "${POSTFLIGHT_MESSAGES[@]}"
info "Setup completed successfully."
info "Reload aliases: source ~/.bash_aliases"
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi
#!/usr/bin/env bash
set -Eeuo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
# shellcheck source=setup_debian_family.sh
source ./setup_debian_family.sh
tests=0
failures=0
pass() {
tests=$((tests + 1))
printf 'ok %d - %s\n' "$tests" "$1"
}
fail() {
tests=$((tests + 1))
failures=$((failures + 1))
printf 'not ok %d - %s\n' "$tests" "$1"
}
expect_success() {
local description="$1"
shift
if "$@" >/dev/null 2>&1; then pass "$description"; else fail "$description"; fi
}
expect_failure() {
local description="$1"
shift
if "$@" >/dev/null 2>&1; then fail "$description"; else pass "$description"; fi
}
expect_equal() {
local description="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then pass "$description"; else fail "$description (expected '$expected', got '$actual')"; fi
}
expect_success "accepts true" parse_bool TEST true
expect_success "accepts false" parse_bool TEST false
expect_failure "rejects invalid boolean" bash -c 'source ./setup_debian_family.sh; parse_bool TEST yes'
expect_equal "root selects SUDO_USER" alice "$(select_target_username 0 root alice)"
expect_equal "non-root selects current user" bob "$(select_target_username 1000 bob ignored)"
expect_failure "root without SUDO_USER is rejected" select_target_username 0 root ""
expect_success "matching SUDO_UID accepted" validate_sudo_identity 1000 1000
expect_failure "mismatched SUDO_UID rejected" validate_sudo_identity 1000 1001
expect_failure "root SUDO_UID rejected" validate_sudo_identity 1000 0
expect_success "ordinary username accepted" validate_username alice
expect_failure "root target rejected" bash -c 'source ./setup_debian_family.sh; validate_username root'
expect_failure "unsafe username rejected" bash -c 'source ./setup_debian_family.sh; validate_username "../alice"'
expect_equal "renders exact sudoers rule" "alice ALL=(ALL:ALL) NOPASSWD: ALL" "$(render_sudoers_rule alice)"
if [[ " ${BOOTSTRAP_COMMANDS[*]} " != *" curl "* ]]; then
pass "bootstrap prerequisites do not require curl"
else
fail "bootstrap prerequisites do not require curl"
fi
expect_failure "required command failure propagates" bash -c '
source ./setup_debian_family.sh
DRY_RUN=false
exec "Expected failure" false
'
exec_output="$(exec "Quiet success" bash -c 'printf noisy-command-output')"
if [[ "$exec_output" == *"Complete: Quiet success."* && "$exec_output" != *"noisy-command-output"* ]]; then
pass "exec keeps successful command output compact"
else
fail "exec keeps successful command output compact"
fi
set +e
exec_failure_output="$(exec "Visible failure" bash -c 'printf failure-detail; exit 7' 2>&1)"
exec_failure_status=$?
set -e
expect_equal "exec preserves command failure status" "7" "$exec_failure_status"
if [[ "$exec_failure_output" == *"Failed: Visible failure (status 7)."* && "$exec_failure_output" == *"failure-detail"* ]]; then
pass "exec reveals captured output on failure"
else
fail "exec reveals captured output on failure"
fi
NO_COLOR=1
init_terminal
plain_info="$(info "Plain output")"
unset NO_COLOR
if [[ "$plain_info" == '[INFO ] Plain output' && "$plain_info" != *$'\033'* ]]; then
pass "NO_COLOR produces plain status tags"
else
fail "NO_COLOR produces plain status tags"
fi
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/setup-pi-tests.XXXXXXXX")"
trap 'rm -rf -- "$fixture_dir"' EXIT
# The single-quoted body intentionally expands $1 in the child shell.
# shellcheck disable=SC2016
expect_failure "symlinked aliases file is rejected" bash -c '
source ./setup_debian_family.sh
target="$1"
ln -s /etc/passwd "$target"
TARGET_USER=alice
TARGET_GROUP=alice
backup_existing_aliases "$target"
' _ "$fixture_dir/aliases-link"
ln -s /etc/passwd "$fixture_dir/personal-link"
TARGET_HOME="$fixture_dir"
mv "$fixture_dir/personal-link" "$fixture_dir/.personal_aliases"
# The single-quoted body intentionally expands $1 in the child shell.
# shellcheck disable=SC2016
expect_failure "symlinked personal aliases file is rejected" bash -c '
source ./setup_debian_family.sh
DRY_RUN=false
TARGET_HOME="$1"
install_aliases
' _ "$fixture_dir"
rm "$fixture_dir/.personal_aliases"
# Distribution fixtures exercise parsing; package operations remain mocked.
apt-get() { :; }
for spec in \
'debian|ID=debian|Debian' \
'ubuntu|ID=ubuntu|Ubuntu' \
'raspbian|ID=raspbian|Raspbian' \
'derived|ID=example,ID_LIKE=debian|Example'; do
IFS='|' read -r label body name <<<"$spec"
body="${body//,/$'\n'}"
fixture="$fixture_dir/$label"
printf '%s\nNAME=%s\nVERSION_ID=1\n' "$body" "$name" >"$fixture"
if (
OS_RELEASE_FILE="$fixture"
detect_distribution
) >/dev/null 2>&1; then
pass "detects $label os-release"
else
fail "detects $label os-release"
fi
done
printf 'ID=fedora\nNAME=Fedora\n' >"$fixture_dir/unsupported"
# The single-quoted body intentionally expands $1 in the child shell.
# shellcheck disable=SC2016
expect_failure "rejects non-Debian distribution" bash -c '
source ./setup_debian_family.sh
OS_RELEASE_FILE="$1"
detect_distribution
' _ "$fixture_dir/unsupported"
DRY_RUN=true
ENABLE_SYSTEM_MAINTENANCE=true
ENABLE_PASSWORDLESS_SUDO=true
TARGET_USER=alice
TARGET_GROUP=alice
TARGET_HOME="$fixture_dir/home"
DISTRO_ID=debian
PRIV=()
mkdir "$TARGET_HOME"
before="$(find "$fixture_dir" -mindepth 1 -maxdepth 3 -print | sort)"
apt_install_dependencies >/dev/null
run_system_maintenance >/dev/null 2>&1
install_aliases >/dev/null
install_helpers >/dev/null
install_passwordless_sudo >/dev/null 2>&1
report_needrestart_policy >/dev/null
after="$(find "$fixture_dir" -mindepth 1 -maxdepth 3 -print | sort)"
expect_equal "dry run makes no persistent changes" "$before" "$after"
expect_equal "dry run creates no work directory" "" "$WORK_DIR"
if bash -n <./setup_debian_family.sh; then
pass "installer parses when read from standard input"
else
fail "installer parses when read from standard input"
fi
if bash < <(sed 's/^ main "$@"$/ : # pipeline guard regression/' ./setup_debian_family.sh) >/dev/null 2>&1; then
pass "stdin execution guard tolerates an empty BASH_SOURCE array"
else
fail "stdin execution guard tolerates an empty BASH_SOURCE array"
fi
cleanup_probe="$fixture_dir-cleanup-probe"
# The single-quoted body intentionally expands $1 in the child shell.
# shellcheck disable=SC2016
expect_failure "failed child command remains nonzero" bash -c '
source ./setup_debian_family.sh
DRY_RUN=false
WORK_DIR="$1"
mkdir "$WORK_DIR"
exec "Expected failure" false
' _ "$cleanup_probe"
if [[ ! -e "$cleanup_probe" ]]; then
pass "failure path cleans private work directory"
else
fail "failure path cleans private work directory"
fi
TARGET_USER=pi
DRY_RUN=false
ENABLE_SYSTEM_MAINTENANCE=true
ENABLE_PASSWORDLESS_SUDO=true
NO_COLOR=1
preflight_output="$(report_preflight 2>&1)"
expected_preflight=$'[INFO ] Dry run disabled.\n[WARN ] System maintenance enabled: upgrades allowed.\n[WARN ] Passwordless sudo enabled for pi.'
expect_equal "preflight messages are compact and grouped" "$expected_preflight" "$preflight_output"
unset NO_COLOR
POSTFLIGHT_MESSAGES=()
defer_info "Queued finding."
queued_output="$(report_postflight "${POSTFLIGHT_MESSAGES[@]}")"
expect_equal "queued information renders on request" "[INFO ] Queued finding." "$queued_output"
flow_output="$(
POSTFLIGHT_MESSAGES=()
init_terminal() { :; }
parse_bool() { :; }
require_commands() { :; }
detect_distribution() { :; }
resolve_target_user() { :; }
configure_privilege() { :; }
create_work_dir() { :; }
report_preflight() { printf '%s\n' PREFLIGHT; }
apt_install_dependencies() { printf '%s\n' CHECKLIST; }
run_system_maintenance() { :; }
install_aliases() { :; }
install_helpers() { :; }
install_passwordless_sudo() { :; }
report_needrestart_policy() { :; }
report_postflight() { printf '%s\n' '[INFO ] Deferred finding.'; }
info() { :; }
main
)"
expect_equal "informational findings follow checklist output" $'PREFLIGHT\nCHECKLIST\n[INFO ] Deferred finding.' "$flow_output"
printf '1..%d\n' "$tests"
((failures == 0))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment