Boot the stick, pick one GRUB entry, walk away: it wipes the target disk, installs Ubuntu 26.04.1 with the NVIDIA driver and codecs, and runs setup.sh on first boot to install the apps. build-iso.sh does the image surgery in a container, so the build machine needs nothing but podman. The wipe is pinned to one disk serial (the 990 PRO). The Data drive can't match it, and an unmatched disk stops the install rather than guessing — so the stick can't eat another machine either. The autoinstall entry is not the GRUB default, so an accidental boot lands in the ordinary Ubuntu installer. Verified by running the install in a VM with two virtual NVMe drives carrying the real serials: unattended start to finish, target partitioned and installed, Data disk byte-for-byte identical, and late-commands left /opt/main-desktop plus the enabled first-boot service in place. Testing caught the autoinstall args landing after '---' (where they reach the installed system instead of the installer, and do nothing), and the first-boot run adding root rather than ckoch to the docker group, since runuser leaves $USER alone. setup.sh gains an unattended mode for that first-boot run, waits out the dpkg lock that Ubuntu's own boot-time upgrades hold, waits for snapd to finish seeding before removing the Firefox snap, and skips the driver step on machines with no NVIDIA card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
297 lines
14 KiB
Bash
Executable File
297 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Fresh Ubuntu 26.04 LTS -> my desktop, in one pass. Run as your user; it sudos as needed.
|
|
# Safe to re-run: each step checks before it changes anything. Reboot when it finishes,
|
|
# then run `sudo flatpak update` once so the Flatpak apps get the NVIDIA GL runtime.
|
|
#
|
|
# UNATTENDED=1 (or --unattended) asks nothing: used by the first-boot run after an
|
|
# install from the ISO, where the driver is already in place.
|
|
set -euo pipefail
|
|
|
|
UNATTENDED=${UNATTENDED:-}
|
|
[[ ${1:-} == --unattended ]] && UNATTENDED=1
|
|
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
# Second NVMe (970 EVO Plus, label "Data"): SteamLibrary + emulator files. Never formatted.
|
|
DATA_UUID=29db4bd3-b743-417d-aa39-4da4acc9df3f
|
|
DATA_MNT=/mnt/data
|
|
# server-marvin SMB share, mounted if you give it credentials. \040 is fstab's escaped space.
|
|
MARVIN_SHARE='//192.168.1.193/Personal\040Files\040-\040ckoch'
|
|
MARVIN_MNT=/mnt/server-marvin-personal
|
|
MARVIN_CREDS=/etc/samba/credentials-marvin
|
|
|
|
step() { printf '\n\033[1;34m== %s ==\033[0m\n' "$*"; }
|
|
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
# Ubuntu's own unattended-upgrades run right after boot, so wait for the dpkg lock rather
|
|
# than failing the moment it's held — that's the difference between a first-boot run that
|
|
# works and one that dies in the first minute.
|
|
APT_OPTS=(-o DPkg::Lock::Timeout=600)
|
|
apt_install() { sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" install -y "$@"; }
|
|
|
|
[[ $EUID -ne 0 ]] || die "run as your user, not with sudo"
|
|
. /etc/os-release
|
|
[[ $ID == ubuntu ]] || die "expected Ubuntu, found $ID"
|
|
[[ $VERSION_ID == 26.04 ]] || warn "written for 26.04; this is $VERSION_ID — carrying on"
|
|
CODENAME=${UBUNTU_CODENAME:-$VERSION_CODENAME}
|
|
# Who we actually are. Not $USER: the first-boot service reaches this through runuser, which
|
|
# leaves $USER as root, and group changes would land on the wrong account.
|
|
ME=$(id -un)
|
|
|
|
# Ask for the password once and keep sudo alive for the whole run (Flatpaks take a while).
|
|
sudo -v
|
|
while true; do sudo -n true; sleep 50; kill -0 "$$" 2>/dev/null || exit; done 2>/dev/null &
|
|
keepalive=$!
|
|
trap 'kill "$keepalive" 2>/dev/null' EXIT
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "Apt repositories"
|
|
sudo apt-get "${APT_OPTS[@]}" update
|
|
apt_install curl gpg ca-certificates software-properties-common
|
|
for c in universe multiverse restricted; do sudo add-apt-repository -y -n "$c"; done
|
|
sudo dpkg --add-architecture i386 # Steam + 32-bit game libraries
|
|
sudo install -d -m 0755 /etc/apt/keyrings
|
|
|
|
# Docker CE — Docker's own repo, not the snap or Ubuntu's docker.io
|
|
if [[ ! -f /etc/apt/sources.list.d/docker.sources ]]; then
|
|
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
|
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
|
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
|
|
Types: deb
|
|
URIs: https://download.docker.com/linux/ubuntu
|
|
Suites: $CODENAME
|
|
Components: stable
|
|
Signed-By: /etc/apt/keyrings/docker.asc
|
|
EOF
|
|
fi
|
|
|
|
# NVIDIA Container Toolkit — GPU access inside Docker/Podman
|
|
if [[ ! -f /etc/apt/sources.list.d/nvidia-container-toolkit.list ]]; then
|
|
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
|
| sudo gpg --dearmor --yes -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
|
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
|
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
|
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null
|
|
fi
|
|
|
|
# VS Code — Microsoft's repo (was rpm-ostree-layered on Bazzite)
|
|
if [[ ! -f /etc/apt/sources.list.d/vscode.sources ]]; then
|
|
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
|
|
| sudo gpg --dearmor --yes -o /usr/share/keyrings/microsoft.gpg
|
|
sudo tee /etc/apt/sources.list.d/vscode.sources >/dev/null <<EOF
|
|
Types: deb
|
|
URIs: https://packages.microsoft.com/repos/code
|
|
Suites: stable
|
|
Components: main
|
|
Architectures: amd64
|
|
Signed-By: /usr/share/keyrings/microsoft.gpg
|
|
EOF
|
|
fi
|
|
|
|
# GitHub CLI
|
|
if [[ ! -f /etc/apt/sources.list.d/github-cli.list ]]; then
|
|
sudo curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
-o /etc/apt/keyrings/githubcli-archive-keyring.gpg
|
|
sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
|
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
| sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null
|
|
fi
|
|
|
|
# Claude desktop — Anthropic's repo (ran inside an Ubuntu distrobox on Bazzite)
|
|
if [[ ! -f /etc/apt/sources.list.d/claude-desktop.list ]]; then
|
|
key=/usr/share/keyrings/claude-desktop-archive-keyring.asc
|
|
sudo curl -fsSLo "$key" https://downloads.claude.ai/claude-desktop/key.asc
|
|
if ! gpg --show-keys --with-colons "$key" 2>/dev/null \
|
|
| grep -q '^fpr:::::::::31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE:'; then
|
|
sudo rm -f "$key"; die "Claude desktop signing key has the wrong fingerprint — not adding the repo"
|
|
fi
|
|
echo "deb [arch=amd64,arm64 signed-by=$key] https://downloads.claude.ai/claude-desktop/apt/stable stable main" \
|
|
| sudo tee /etc/apt/sources.list.d/claude-desktop.list >/dev/null
|
|
fi
|
|
|
|
sudo apt-get "${APT_OPTS[@]}" update
|
|
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_OPTS[@]}" full-upgrade -y
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "Packages"
|
|
# steam-installer refuses to install non-interactively until its license is accepted.
|
|
echo 'steam-installer steam/question select I AGREE' | sudo debconf-set-selections
|
|
echo 'steam-installer steam/license note ' | sudo debconf-set-selections
|
|
|
|
apt_install \
|
|
build-essential cmake pkg-config git git-lfs wget jq zstd unzip p7zip-full rsync htop \
|
|
python3-venv python3-pip pipx ffmpeg cifs-utils flatpak \
|
|
steam-installer steam-devices gamemode mangohud lutris vulkan-tools \
|
|
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|
|
nvidia-container-toolkit \
|
|
code claude-desktop
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "NVIDIA driver (open kernel modules — the only kind the RTX 5090 supports)"
|
|
installed_driver() {
|
|
{ dpkg-query -W -f='${Status} ${Package}\n' 'nvidia-driver-*' 2>/dev/null || true; } \
|
|
| awk '/^install ok installed / {print $4}' | sort -V | tail -1
|
|
}
|
|
if ! grep -qx '0x10de' /sys/bus/pci/devices/*/vendor 2>/dev/null; then
|
|
# No NVIDIA card here at all (a VM, say) — nothing to install, and nothing to fail over.
|
|
warn "no NVIDIA GPU on this machine — skipping the driver"
|
|
else
|
|
apt_install ubuntu-drivers-common
|
|
if [[ $(installed_driver) != *-open ]]; then
|
|
ubuntu-drivers devices 2>/dev/null | grep -E 'model|driver' || true
|
|
# Picks Ubuntu's recommended branch plus its prebuilt, signed kernel modules (no DKMS).
|
|
sudo ubuntu-drivers install
|
|
fi
|
|
driver=$(installed_driver)
|
|
[[ $driver == nvidia-driver-*-open ]] \
|
|
|| die "got '${driver:-no driver}', but the 5090 needs an -open driver — check 'ubuntu-drivers devices'"
|
|
branch=${driver#nvidia-driver-}; branch=${branch%-open}
|
|
apt_install "libnvidia-gl-$branch:i386" # 32-bit GL/Vulkan for Steam and Proton
|
|
echo "using $driver"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "Docker, GPU containers, Claude's Cowork VM"
|
|
sudo usermod -aG docker "$ME" # docker without sudo (after reboot)
|
|
getent group kvm >/dev/null && sudo usermod -aG kvm "$ME" # Cowork needs /dev/kvm + vhost-vsock
|
|
echo vhost_vsock | sudo tee /etc/modules-load.d/vhost_vsock.conf >/dev/null
|
|
if ! grep -q nvidia /etc/docker/daemon.json 2>/dev/null; then
|
|
sudo nvidia-ctk runtime configure --runtime=docker # enables `docker run --gpus all`
|
|
sudo systemctl restart docker
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "Flatpak apps"
|
|
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
|
mapfile -t apps < <(sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' "$REPO/flatpaks.txt")
|
|
sudo flatpak install -y --noninteractive --system flathub "${apps[@]}"
|
|
# Firefox's read-only peek at landingPage, as set by hand on Bazzite
|
|
flatpak override --user org.mozilla.firefox --filesystem="$HOME/Documents/git/landingPage:ro"
|
|
|
|
# Firefox/Thunderbird come from Flathub instead, so copying ~/.var/app over brings the old
|
|
# profiles with them. Drop Ubuntu's snaps and the debs that pull them back in.
|
|
# On a first boot snapd may still be seeding, and a remove during that fails — wait it out,
|
|
# and treat a failure here as a warning rather than losing the rest of the run.
|
|
sudo snap wait system seed.loaded 2>/dev/null || true
|
|
for s in firefox thunderbird; do
|
|
if snap list "$s" >/dev/null 2>&1; then
|
|
sudo snap remove --purge "$s" || warn "couldn't remove the $s snap — remove it by hand later"
|
|
fi
|
|
if dpkg -s "$s" >/dev/null 2>&1; then
|
|
sudo apt-get "${APT_OPTS[@]}" remove -y "$s" || warn "couldn't remove the $s deb"
|
|
fi
|
|
done
|
|
xdg-settings set default-web-browser org.mozilla.firefox.desktop 2>/dev/null || true
|
|
|
|
# Keep Flatpaks updated daily, like Bazzite did. Starts next boot.
|
|
sudo tee /etc/systemd/system/flatpak-update.service >/dev/null <<'EOF'
|
|
[Unit]
|
|
Description=Update system Flatpaks
|
|
Wants=network-online.target
|
|
After=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/usr/bin/flatpak update --system --noninteractive --assumeyes
|
|
EOF
|
|
sudo tee /etc/systemd/system/flatpak-update.timer >/dev/null <<'EOF'
|
|
[Unit]
|
|
Description=Update system Flatpaks daily
|
|
|
|
[Timer]
|
|
OnBootSec=10min
|
|
OnUnitActiveSec=1d
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable flatpak-update.timer
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "Git credentials in the keyring"
|
|
# On Bazzite, KDE Wallet fed git your Gitea password through ksshaskpass. GNOME's keyring does
|
|
# the same job via git's libsecret helper, which Ubuntu ships as source only — so build it.
|
|
if ! git config --global --get credential.helper >/dev/null 2>&1; then
|
|
helper="" src=/usr/share/doc/git/contrib/credential/libsecret
|
|
if [[ -e $src/git-credential-libsecret.c || -e $src/git-credential-libsecret.c.gz ]]; then
|
|
apt_install libsecret-1-dev libglib2.0-dev
|
|
tmp=$(mktemp -d)
|
|
if [[ -e $src/git-credential-libsecret.c.gz ]]; then
|
|
zcat "$src/git-credential-libsecret.c.gz" > "$tmp/h.c"
|
|
else
|
|
cp "$src/git-credential-libsecret.c" "$tmp/h.c"
|
|
fi
|
|
read -ra cf < <(pkg-config --cflags libsecret-1 glib-2.0)
|
|
read -ra lf < <(pkg-config --libs libsecret-1 glib-2.0)
|
|
if cc -O2 "${cf[@]}" "$tmp/h.c" -o "$tmp/git-credential-libsecret" "${lf[@]}" 2>/dev/null; then
|
|
sudo install -m 755 "$tmp/git-credential-libsecret" /usr/local/bin/
|
|
helper=/usr/local/bin/git-credential-libsecret
|
|
fi
|
|
rm -rf "$tmp"
|
|
fi
|
|
if [[ -n $helper ]]; then
|
|
git config --global credential.helper "$helper"
|
|
else
|
|
git config --global credential.helper 'cache --timeout=604800'
|
|
warn "no keyring helper — git will hold your Gitea password for a week at a time instead"
|
|
fi
|
|
fi
|
|
|
|
step "Claude Code CLI"
|
|
# No Node or .NET on the host by choice — LudosData and landingPage build in Docker.
|
|
# If you want them back: sudo apt install mise dotnet-sdk-10.0 (mise needs ppa:jdxcode/mise).
|
|
command -v claude >/dev/null || [[ -x ~/.local/bin/claude ]] || curl -fsSL https://claude.ai/install.sh | bash
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "Data drive + server-marvin share"
|
|
sudo mkdir -p "$DATA_MNT" "$MARVIN_MNT"
|
|
if ! grep -q "$DATA_UUID" /etc/fstab; then
|
|
echo "UUID=$DATA_UUID $DATA_MNT ext4 defaults,nofail,x-gvfs-show,x-gvfs-name=Data 0 2" \
|
|
| sudo tee -a /etc/fstab >/dev/null
|
|
fi
|
|
sudo blkid -U "$DATA_UUID" >/dev/null || warn "Data drive ($DATA_UUID) not found — fstab entry is nofail, so boot is fine"
|
|
|
|
if [[ ! -f $MARVIN_CREDS && -z $UNATTENDED ]]; then
|
|
echo "server-marvin SMB share — press Enter to skip mounting it."
|
|
read -rp " username: " smb_user
|
|
if [[ -n $smb_user ]]; then
|
|
read -rsp " password: " smb_pass; echo
|
|
sudo install -d -m 0755 /etc/samba
|
|
printf 'username=%s\npassword=%s\n' "$smb_user" "$smb_pass" \
|
|
| sudo install -m 600 /dev/stdin "$MARVIN_CREDS"
|
|
unset smb_pass
|
|
fi
|
|
fi
|
|
if [[ -f $MARVIN_CREDS ]] && ! grep -q "$MARVIN_MNT" /etc/fstab; then
|
|
echo "$MARVIN_SHARE $MARVIN_MNT cifs credentials=$MARVIN_CREDS,uid=$(id -u),gid=$(id -g),iocharset=utf8,vers=3.0,x-systemd.automount,_netdev,nofail 0 0" \
|
|
| sudo tee -a /etc/fstab >/dev/null
|
|
fi
|
|
sudo systemctl daemon-reload
|
|
sudo mount -a || warn "a mount failed — check 'sudo mount -a' (is server-marvin up? right password?)"
|
|
|
|
# ---------------------------------------------------------------------------------------
|
|
step "Small carry-overs from Bazzite"
|
|
# Xbox controllers over Bluetooth (Bazzite set this as a kernel argument)
|
|
echo 'options bluetooth disable_ertm=1' | sudo tee /etc/modprobe.d/bluetooth-xbox.conf >/dev/null
|
|
|
|
step "Done"
|
|
if [[ -n $UNATTENDED ]]; then
|
|
cat <<'EOF'
|
|
Log out and back in for the docker and kvm groups.
|
|
Then sign in to your apps, and add /mnt/data/SteamLibrary in Steam > Settings > Storage.
|
|
EOF
|
|
else
|
|
cat <<'EOF'
|
|
Reboot now — the NVIDIA driver and your docker/kvm groups only take effect after that.
|
|
|
|
Then, once:
|
|
sudo flatpak update # pulls the NVIDIA GL runtime the Flatpak apps need.
|
|
# Skip it and apps render in software until the daily
|
|
# timer catches up ~10 minutes after boot.
|
|
|
|
Then sign in to things, and add /mnt/data/SteamLibrary in Steam > Settings > Storage.
|
|
EOF
|
|
fi
|