docs(setup): add Debian 13 dev-VM setup procedure + scripts + devcontainer
CI / commits (pull_request) Successful in 2m52s
CI / check (pull_request) Successful in 3m3s
CI / scan (pull_request) Successful in 3m11s
CI / a11y (pull_request) Successful in 3m5s
Docs site / build (pull_request) Successful in 3m10s
CI / perf (pull_request) Successful in 6m53s

New procedure in docs/setup/01-dev-debian-vm-setup.md targeting the new
dev VM (10.100.201.21, Debian 13) that replaces the WSL workflow. The
existing setup files are renumbered (01->02, 02->03, 03->04) to free
prefix 01 for the default flow.

10 modular idempotent scripts under docs/setup/scripts/ install the
stack: zsh + Oh My Zsh + Powerlevel10k + plugins, modern CLI tooling
(bat, eza, fd-find, ripgrep, fzf, zoxide, ncdu, keychain + dev extras),
nvm + Node from .nvmrc + corepack + pnpm, Docker CE + compose plugin,
inotify watches + swap + hostname tuning, best-effort UFW + sshd
hardening that probes-before-applying, and notes/aliases.zsh +
notes/gitconfig.txt installation.

A systemd template (apf-portal-infra@.service) auto-starts the local
dev infra stack at boot per user.

.devcontainer/ ships a VSCode Dev Container spec pinning Node 24 and
attaching to the host's apf-portal-dev Compose network so postgres /
redis / otel resolve by DNS from inside the container. initializeCommand
fails fast if the infra hasn't been brought up yet.

CLAUDE.md Environment conventions documents the two envs (local +
development) plus the hybrid sub-mode (workstation IDE + VM infra via
SSH LocalForward) and the two IDE flows (Remote-SSH + Devcontainer).

Adjacent follow-ups (not in this PR): preview infra on the GitLab VM
(10.100.201.10), GitLab Runner migration alongside the Gitea -> GitLab
cutover, private dotfiles repo.
This commit is contained in:
Julien Gautier
2026-05-24 18:37:57 +02:00
parent 0d8b3712fb
commit f70b1d3a1b
19 changed files with 1236 additions and 22 deletions
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# apt update + install base packages every other script assumes.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
log "apt update"
sudo apt-get update -qq
apt_install \
curl wget git ca-certificates gnupg lsb-release \
software-properties-common apt-transport-https \
build-essential pkg-config
ok "Base packages ready."
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# zsh + Oh My Zsh + Powerlevel10k + plugins (autosuggestions, syntax-highlighting).
# Patches ~/.zshrc to set the theme, plugin list, fzf hook.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
apt_install zsh
# Make zsh the default shell for $USER (idempotent).
current_shell="$(getent passwd "$USER" | cut -d: -f7)"
zsh_path="$(command -v zsh)"
if [[ "$current_shell" != "$zsh_path" ]]; then
log "Setting zsh as default shell for $USER (sudo will prompt)."
sudo chsh -s "$zsh_path" "$USER"
ok "Default shell set to $zsh_path. Takes effect on next login."
else
skip "zsh already default shell."
fi
# Oh My Zsh — install with RUNZSH=no to avoid spawning zsh during the script.
OMZ_DIR="$HOME/.oh-my-zsh"
if [[ -d "$OMZ_DIR" ]]; then
skip "Oh My Zsh already installed."
else
log "Installing Oh My Zsh."
RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
ok "Oh My Zsh installed."
fi
ZSH_CUSTOM_DIR="${ZSH_CUSTOM:-$OMZ_DIR/custom}"
clone_or_skip() {
local name="$1" url="$2" dir="$3"
if [[ -d "$dir" ]]; then
skip "$name already cloned at $dir."
else
log "Cloning $name."
git clone --depth=1 "$url" "$dir"
ok "$name installed."
fi
}
# Powerlevel10k theme.
clone_or_skip "Powerlevel10k" \
"https://github.com/romkatv/powerlevel10k.git" \
"$ZSH_CUSTOM_DIR/themes/powerlevel10k"
# Plugins.
clone_or_skip "zsh-autosuggestions" \
"https://github.com/zsh-users/zsh-autosuggestions.git" \
"$ZSH_CUSTOM_DIR/plugins/zsh-autosuggestions"
clone_or_skip "zsh-syntax-highlighting" \
"https://github.com/zsh-users/zsh-syntax-highlighting.git" \
"$ZSH_CUSTOM_DIR/plugins/zsh-syntax-highlighting"
# Patch ~/.zshrc — theme + plugin list + fzf hook.
ZSHRC="$HOME/.zshrc"
[[ -f "$ZSHRC" ]] || touch "$ZSHRC"
if grep -q '^ZSH_THEME=' "$ZSHRC"; then
if grep -q '^ZSH_THEME="powerlevel10k/powerlevel10k"' "$ZSHRC"; then
skip "ZSH_THEME already powerlevel10k."
else
sed -i 's#^ZSH_THEME=.*#ZSH_THEME="powerlevel10k/powerlevel10k"#' "$ZSHRC"
ok "ZSH_THEME → powerlevel10k/powerlevel10k."
fi
else
echo 'ZSH_THEME="powerlevel10k/powerlevel10k"' >> "$ZSHRC"
ok 'Set ZSH_THEME=powerlevel10k/powerlevel10k.'
fi
PLUGINS_LINE='plugins=(git fzf zoxide direnv zsh-autosuggestions zsh-syntax-highlighting)'
if grep -q '^plugins=' "$ZSHRC"; then
if grep -q "^$PLUGINS_LINE" "$ZSHRC"; then
skip "plugins=(...) already configured."
else
sed -i "s#^plugins=.*#$PLUGINS_LINE#" "$ZSHRC"
ok "plugins=(...) updated."
fi
else
echo "$PLUGINS_LINE" >> "$ZSHRC"
ok "plugins=(...) added."
fi
ensure_line '[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh' "$ZSHRC"
ok "zsh + Oh My Zsh + Powerlevel10k ready."
log "Next: run 'exec zsh -l' then 'p10k configure' to pick a Powerlevel10k preset."
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Modern CLI tools — what notes/aliases.zsh assumes + a fullstack-dev kit.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
# Requested in the project brief.
apt_install bat eza fd-find ripgrep fzf zoxide ncdu keychain
# Useful extras for a fullstack dev workflow.
apt_install jq yq httpie make tree htop tmux direnv dnsutils unzip rsync
# Debian renames `bat`→`batcat` and `fd`→`fdfind` to avoid binary
# conflicts. notes/aliases.zsh aliases the Debian names back, but a
# direct binary on PATH is more robust (works for non-interactive
# scripts and other shells). Symlink in ~/.local/bin.
mkdir -p "$HOME/.local/bin"
for pair in "batcat:bat" "fdfind:fd"; do
src="${pair%%:*}"
dst="${pair##*:}"
src_path="$(command -v "$src" 2>/dev/null || true)"
if [[ -z "$src_path" ]]; then
warn "$src not found on PATH — skipping symlink."
continue
fi
if [[ -L "$HOME/.local/bin/$dst" && "$(readlink "$HOME/.local/bin/$dst")" == "$src_path" ]]; then
skip "$HOME/.local/bin/$dst already linked to $src."
elif [[ -e "$HOME/.local/bin/$dst" ]]; then
skip "$HOME/.local/bin/$dst exists (not a managed symlink) — leaving as-is."
else
ln -s "$src_path" "$HOME/.local/bin/$dst"
ok "Linked $HOME/.local/bin/$dst$src."
fi
done
# Make sure ~/.local/bin is on PATH for both bash and zsh.
for rcfile in "$HOME/.bashrc" "$HOME/.zshrc"; do
[[ -f "$rcfile" ]] || touch "$rcfile"
ensure_line 'export PATH="$HOME/.local/bin:$PATH"' "$rcfile"
done
ok "CLI tools installed."
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# nvm + Node from the repo's .nvmrc + corepack + pnpm at the version pinned
# in package.json's packageManager field.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
NVM_VERSION="v0.40.1"
NVM_DIR="$HOME/.nvm"
if [[ -d "$NVM_DIR" ]]; then
skip "nvm already installed at $NVM_DIR."
else
log "Installing nvm $NVM_VERSION."
curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh" | bash
ok "nvm installed."
fi
# Source nvm so we can use it in this shell.
export NVM_DIR
# shellcheck disable=SC1091
source "$NVM_DIR/nvm.sh"
# Read .nvmrc from the repo root.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$REPO_ROOT" ]]; then
err "Run from inside a git checkout of apf_portal (no repo root found)."
exit 1
fi
if [[ ! -f "$REPO_ROOT/.nvmrc" ]]; then
warn "$REPO_ROOT/.nvmrc not found — installing Node LTS as fallback."
nvm install --lts
nvm alias default 'lts/*'
else
NODE_VER="$(<"$REPO_ROOT/.nvmrc")"
log "Installing Node $NODE_VER (from $REPO_ROOT/.nvmrc)."
nvm install "$NODE_VER"
nvm alias default "$NODE_VER"
ok "Node $(node --version) active."
fi
# corepack — provides pnpm + yarn shims, reads packageManager from package.json.
corepack enable
ok "corepack enabled."
# Warm pnpm to download the pinned version.
if [[ -f "$REPO_ROOT/package.json" ]]; then
pushd "$REPO_ROOT" >/dev/null
pnpm --version >/dev/null
ok "pnpm $(pnpm --version) ready (from package.json packageManager)."
popd >/dev/null
fi
log "Reload your shell (exec zsh -l) so nvm + node + pnpm are on PATH."
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Docker CE + compose plugin from the docker.com apt repo. Adds the
# current user to the `docker` group and enables the daemon at boot.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
if command -v docker >/dev/null 2>&1 \
&& docker compose version >/dev/null 2>&1; then
skip "docker + compose plugin already present."
else
log "Installing Docker CE from docker.com apt repo."
# GPG key.
sudo install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
curl -fsSL https://download.docker.com/linux/debian/gpg \
| sudo tee /etc/apt/keyrings/docker.asc >/dev/null
sudo chmod a+r /etc/apt/keyrings/docker.asc
fi
# Repo definition.
ARCH="$(dpkg --print-architecture)"
# shellcheck disable=SC1091
CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME}")"
REPO_LINE="deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${CODENAME} stable"
if [[ -f /etc/apt/sources.list.d/docker.list ]] \
&& grep -qxF "$REPO_LINE" /etc/apt/sources.list.d/docker.list; then
skip "docker.list already configured for $CODENAME."
else
echo "$REPO_LINE" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
ok "Wrote /etc/apt/sources.list.d/docker.list."
fi
sudo apt-get update -qq
apt_install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
ok "Docker installed."
fi
# Add user to docker group.
if id -nG "$USER" | grep -qw docker; then
skip "User $USER already in docker group."
else
sudo usermod -aG docker "$USER"
warn "Added $USER to docker group — log out and back in for it to apply."
fi
# Enable docker.service at boot.
if systemctl is-enabled docker.service >/dev/null 2>&1; then
skip "docker.service already enabled at boot."
else
sudo systemctl enable --now docker.service
ok "docker.service enabled and started."
fi
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# System-level tuning for Node / Vite / Nx development on a Debian VM:
# - inotify watches limit (default 8K is too low for monorepos)
# - hostname (prompt only if generic)
# - swapfile (4 GB, only if none exists)
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
# 1) inotify watches.
SYSCTL_FILE="/etc/sysctl.d/99-apf-portal.conf"
INOTIFY_LINE="fs.inotify.max_user_watches=524288"
if sudo grep -qxF "$INOTIFY_LINE" "$SYSCTL_FILE" 2>/dev/null; then
skip "inotify watches already configured in $SYSCTL_FILE."
else
echo "$INOTIFY_LINE" | sudo tee "$SYSCTL_FILE" >/dev/null
sudo sysctl --system >/dev/null
ok "fs.inotify.max_user_watches → 524288."
fi
# 2) Hostname — only prompt if it looks generic.
current_hostname="$(hostname)"
case "$current_hostname" in
debian|debian13|localhost|trixie|debian12)
read -r -p "Current hostname '$current_hostname' looks generic. New hostname (blank to keep): " new_hostname
if [[ -n "$new_hostname" ]]; then
sudo hostnamectl set-hostname "$new_hostname"
ok "Hostname set to '$new_hostname'. Will reflect on next login."
else
skip "Hostname kept as '$current_hostname'."
fi
;;
*)
skip "Hostname '$current_hostname' looks intentional — keeping."
;;
esac
# 3) Swap.
if swapon --show | grep -q .; then
skip "Swap already active: $(swapon --show --noheadings | head -1 | awk '{print $1, $3}')."
else
if confirm "No swap detected. Add a 4 GB swapfile at /swapfile?"; then
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
ensure_line_sudo "/swapfile none swap sw 0 0" /etc/fstab
ok "4 GB swapfile active."
else
skip "No swap configured (user choice)."
fi
fi
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Best-effort VM hardening — probes current state, only applies what's missing.
# Skips cleanly when infra has already configured a step.
# - UFW (allow SSH only)
# - unattended-upgrades (security channel)
# - fail2ban
# - sshd: disable root login + password auth (always validates first)
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
log "Hardening pass — each section probes and skips if already done."
# ─── 1. UFW ────────────────────────────────────────────────────────────
if ! command -v ufw >/dev/null; then
apt_install ufw
fi
if sudo ufw status | head -1 | grep -q 'Status: active'; then
skip "UFW already active. Current rules:"
sudo ufw status numbered | sed 's/^/ /'
else
if confirm "Enable UFW with 'deny incoming, allow SSH'?"; then
sudo ufw default deny incoming >/dev/null
sudo ufw default allow outgoing >/dev/null
sudo ufw allow OpenSSH >/dev/null
sudo ufw --force enable
ok "UFW enabled (SSH-only ingress)."
else
skip "UFW left disabled."
fi
fi
# ─── 2. unattended-upgrades ────────────────────────────────────────────
apt_install unattended-upgrades
AUTO_UP="/etc/apt/apt.conf.d/20auto-upgrades"
if [[ -f "$AUTO_UP" ]] \
&& sudo grep -q 'APT::Periodic::Update-Package-Lists "1"' "$AUTO_UP" \
&& sudo grep -q 'APT::Periodic::Unattended-Upgrade "1"' "$AUTO_UP"; then
skip "unattended-upgrades already enabled."
else
cat <<'EOF' | sudo tee "$AUTO_UP" >/dev/null
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
ok "unattended-upgrades enabled."
fi
# ─── 3. fail2ban ───────────────────────────────────────────────────────
apt_install fail2ban
if systemctl is-active fail2ban.service >/dev/null 2>&1; then
skip "fail2ban already running."
else
sudo systemctl enable --now fail2ban.service
ok "fail2ban started + enabled at boot."
fi
# ─── 4. sshd hardening ────────────────────────────────────────────────
# Drop-in file rather than editing /etc/ssh/sshd_config directly.
# We always validate with `sshd -t` before reloading the daemon.
SSHD_DROPIN="/etc/ssh/sshd_config.d/99-apf-portal-hardening.conf"
if sudo test -f "$SSHD_DROPIN"; then
skip "sshd hardening drop-in already at $SSHD_DROPIN."
else
warn "About to disable password auth + root login on SSH."
warn "Make sure your SSH key already works from your workstation."
if confirm "Continue?"; then
cat <<'EOF' | sudo tee "$SSHD_DROPIN" >/dev/null
# Managed by docs/setup/scripts/70-hardening.sh.
# To override or extend, drop another file in /etc/ssh/sshd_config.d/.
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
AllowAgentForwarding yes
EOF
if sudo sshd -t 2>/dev/null; then
sudo systemctl reload ssh.service 2>/dev/null || sudo systemctl reload sshd.service
ok "sshd reloaded with hardened config."
else
err "sshd config validation failed — removing $SSHD_DROPIN."
sudo rm -f "$SSHD_DROPIN"
exit 1
fi
else
skip "sshd hardening declined."
fi
fi
ok "Hardening pass complete."
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Wire up the project-specific dotfiles:
# - symlink notes/aliases.zsh → ~/.oh-my-zsh/custom/aliases.zsh
# - copy notes/gitconfig.txt to ~/.gitconfig and overlay your identity
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$REPO_ROOT" ]]; then
err "Run from inside a git checkout of apf_portal."
exit 1
fi
# ─── 1. Aliases ───────────────────────────────────────────────────────
SOURCE_ALIASES="$REPO_ROOT/notes/aliases.zsh"
if [[ ! -f "$SOURCE_ALIASES" ]]; then
err "$SOURCE_ALIASES not found."
exit 1
fi
OMZ_CUSTOM="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}"
if [[ ! -d "$OMZ_CUSTOM" ]]; then
err "$OMZ_CUSTOM not found — run 20-zsh.sh first."
exit 1
fi
TARGET_ALIASES="$OMZ_CUSTOM/aliases.zsh"
if [[ -L "$TARGET_ALIASES" \
&& "$(readlink -f "$TARGET_ALIASES")" == "$(readlink -f "$SOURCE_ALIASES")" ]]; then
skip "aliases.zsh symlink already correct."
else
if [[ -e "$TARGET_ALIASES" || -L "$TARGET_ALIASES" ]]; then
backup="$TARGET_ALIASES.bak.$(date +%s)"
mv "$TARGET_ALIASES" "$backup"
warn "Existing $TARGET_ALIASES backed up to $backup."
fi
ln -s "$SOURCE_ALIASES" "$TARGET_ALIASES"
ok "Linked $TARGET_ALIASES$SOURCE_ALIASES."
fi
# ─── 2. gitconfig — copy + identity overlay ───────────────────────────
SOURCE_GITCONFIG="$REPO_ROOT/notes/gitconfig.txt"
if [[ ! -f "$SOURCE_GITCONFIG" ]]; then
err "$SOURCE_GITCONFIG not found."
exit 1
fi
TARGET_GITCONFIG="$HOME/.gitconfig"
if [[ -f "$TARGET_GITCONFIG" ]] && ! confirm "$TARGET_GITCONFIG exists — overwrite (existing backed up)?"; then
skip "Keeping existing $TARGET_GITCONFIG."
else
if [[ -f "$TARGET_GITCONFIG" ]]; then
cp "$TARGET_GITCONFIG" "$TARGET_GITCONFIG.bak.$(date +%s)"
fi
# Prompt for identity. Defaults: pre-existing config values, else blank.
current_name="$(git config --global user.name 2>/dev/null || true)"
current_email="$(git config --global user.email 2>/dev/null || true)"
read -r -p "Git user.name [${current_name:-<required>}]: " new_name
new_name="${new_name:-$current_name}"
read -r -p "Git user.email [${current_email:-<required>}]: " new_email
new_email="${new_email:-$current_email}"
if [[ -z "$new_name" || -z "$new_email" ]]; then
err "Both user.name and user.email are required."
exit 1
fi
cp "$SOURCE_GITCONFIG" "$TARGET_GITCONFIG"
# `git config --global` finds and rewrites the [user] section
# cleanly even when the source carries a different identity.
git config --global user.name "$new_name"
git config --global user.email "$new_email"
ok "Wrote $TARGET_GITCONFIG with identity $new_name <$new_email>."
fi
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Orchestrator — runs every numbered setup script in order with a confirmation prompt.
# Each script is also runnable individually:
# ./docs/setup/scripts/40-node.sh
# See docs/setup/01-dev-debian-vm-setup.md for the step-by-step doc.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=./lib.sh
source "$HERE/lib.sh"
require_debian
require_not_root
SCRIPTS=(
10-base-packages.sh
20-zsh.sh
30-cli-tools.sh
40-node.sh
50-docker.sh
60-tuning.sh
70-hardening.sh
80-dotfiles.sh
)
log "Bootstrap will run, in order:"
for s in "${SCRIPTS[@]}"; do
printf ' - %s\n' "$s"
done
echo
if ! confirm "Continue?"; then
log "Aborted before any change."
exit 0
fi
for s in "${SCRIPTS[@]}"; do
echo
log "═══ $s ═══"
if confirm "Run $s now?"; then
"$HERE/$s"
ok "$s done"
else
skip "$s"
fi
done
echo
ok "Bootstrap complete."
log "Next steps:"
printf ' - exec zsh -l # reload shell to pick up zsh + PATH\n'
printf ' - p10k configure # only if you skipped during 20-zsh.sh\n'
printf ' - cp infra/local/.env.example infra/local/.env && $EDITOR infra/local/.env\n'
printf ' - ./infra/local/dev.sh up\n'
printf ' - pnpm install\n'
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Shared helpers for docs/setup/scripts/*.sh — sourced from each script.
# Idempotency utilities + nice logging.
set -euo pipefail
if [[ -t 1 ]]; then
C_GREEN=$'\033[1;32m'
C_YELLOW=$'\033[1;33m'
C_RED=$'\033[1;31m'
C_BLUE=$'\033[1;34m'
C_DIM=$'\033[2m'
C_RESET=$'\033[0m'
else
C_GREEN= C_YELLOW= C_RED= C_BLUE= C_DIM= C_RESET=
fi
log() { printf '%s[%s]%s %s\n' "$C_BLUE" "$(date +%H:%M:%S)" "$C_RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '%s⚠%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
err() { printf '%s✗%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
skip() { printf '%s↪ skip%s %s\n' "$C_DIM" "$C_RESET" "$*"; }
require_debian() {
if ! grep -q '^ID=debian' /etc/os-release 2>/dev/null; then
err "This script targets Debian (read from /etc/os-release)."
exit 1
fi
}
require_not_root() {
if [[ $EUID -eq 0 ]]; then
err "Run as your regular user (sudo is invoked where needed)."
exit 1
fi
}
confirm() {
local prompt="${1:-Proceed?}"
read -r -p "$prompt [y/N] " ans
[[ "$ans" =~ ^[yY]$ ]]
}
# Install apt packages, skipping those already present. Idempotent.
apt_install() {
local missing=()
for pkg in "$@"; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
missing+=("$pkg")
fi
done
if (( ${#missing[@]} == 0 )); then
skip "All packages already present: $*"
return 0
fi
log "apt install: ${missing[*]}"
sudo apt-get install -y "${missing[@]}"
ok "Installed: ${missing[*]}"
}
# Append a line to a file iff not already present (exact match). Idempotent.
ensure_line() {
local line="$1"
local file="$2"
if grep -qxF -- "$line" "$file" 2>/dev/null; then
skip "Already in $(basename "$file"): $line"
else
echo "$line" >> "$file"
ok "Appended to $file: $line"
fi
}
# Same but with sudo (system files).
ensure_line_sudo() {
local line="$1"
local file="$2"
if sudo grep -qxF -- "$line" "$file" 2>/dev/null; then
skip "Already in $(basename "$file"): $line"
else
echo "$line" | sudo tee -a "$file" >/dev/null
ok "Appended to $file: $line"
fi
}