Arch laptop runbook

Plain Arch on a Framework 13 (or any UEFI x86-64 laptop). Manual install,
LUKS2 + ext4, systemd-boot, Hyprland. Substitute your own username
(ppalmer), hostname (prismo) and GitHub account where they appear.

Two boots, two stages. Stage 1 runs from the USB installer as root and builds
the machine; Stage 2 runs from the installed system as your user and restores
your home directory.

Stage 1 — From the USB installer

Run as root in the live ISO. Everything before arch-chroot runs in the live
ISO; everything after it runs inside the chroot until exit.

  1. Boot the official Arch ISO. Verify firmware is UEFI:

    cat /sys/firmware/efi/fw_platform_size      # 64
    

    (If empty, the machine is BIOS and this plan needs GRUB instead of
    systemd-boot.)

  2. Network: iwctl (wifi) or ethernet, then ping archlinux.org.
  3. timedatectl set-ntp true

Partition

Adjust the device name to your disk:

fdisk /dev/nvme0n1
# 1 GiB  type "EFI System"
# rest   type "Linux filesystem"

Encrypt and format

cryptsetup luksFormat --type luks2 /dev/nvme0n1p2
cryptsetup open /dev/nvme0n1p2 cryptroot
mkfs.fat -F 32 /dev/nvme0n1p1
mkfs.ext4 /dev/mapper/cryptroot

Mount and pacstrap

mount /dev/mapper/cryptroot /mnt
mount --mkdir /dev/nvme0n1p1 /mnt/boot
pacstrap -K /mnt base linux linux-firmware amd-ucode base-devel networkmanager nano
genfstab -U /mnt >> /mnt/etc/fstab

Configure inside the chroot

arch-chroot /mnt
ln -sf /usr/share/zoneinfo/<Region>/<City> /etc/localtime
hwclock --systohc
echo 'prismo' > /etc/hostname

Locale

sed -i 's/^#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
locale-gen
echo 'LANG=en_US.UTF-8' > /etc/locale.conf

Skip it and every later pacman call emits locale warnings.

Bootloader

Bootloader first: bootctl install creates /boot/loader/, so neither config
file below can be written before it runs.

bootctl install

/boot/loader/loader.conf:

default arch.conf
timeout 7
console-mode max
editor no

/boot/loader/entries/arch.conf:

title   Arch Linux
linux   /vmlinuz-linux
initrd  /amd-ucode.img
initrd  /initramfs-linux.img
options rd.luks.name=<LUKS-UUID>=cryptroot root=/dev/mapper/cryptroot rw

The UUID is the LUKS container’s (cryptsetup luksDump /dev/nvme0n1p2, or
blkid line with TYPE="crypto_LUKS"). The options keyword is required.
Getting any of this wrong hangs at
A start job is running for /dev/mapper/cryptroot.

In /etc/mkinitcpio.conf, uncomment the HOOKS line that contains sd-encrypt,
then build the initramfs image:

# HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck)
mkinitcpio -P

User and sudo

useradd -m -G wheel -s /bin/bash ppalmer
echo '%wheel ALL=(ALL:ALL) ALL' > /etc/sudoers.d/10-wheel
chmod 0440 /etc/sudoers.d/10-wheel
visudo -c -f /etc/sudoers.d/10-wheel     # always validate
passwd                  # root
passwd ppalmer
systemctl enable NetworkManager

visudo -c is not optional: a malformed sudoers file locks you out of sudo,
and on the laptop you would find out after the reboot.

Reboot

Exit, unmount, reboot, and remove the USB when it powers back up:

exit
umount -R /mnt
reboot

Stage 2 — From the installed system

Boot into Arch and log in at the TTY as your user. .bash_profile sources
.bashrc, which drops you into tmux; from here on everything is user-level.

Bootstrap deadlock

On a fresh machine chezmoi init cannot clone this repo, for two independent
reasons:

The dependency is circular (repo → packages.yamlopensshssh → clone
repo) and has to be broken by hand:

sudo pacman -S --needed sudo git openssh chezmoi

SSH key

The one native-Linux difference: this machine holds its own key, not the
1Password vault.

ssh-keygen -t ed25519 -a 100 -C "ppalmer@prismo" -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub     # register with GitHub as auth and signing key

systemctl --user start ssh-agent.socket                          # agent up before the clone
SSH_AUTH_SOCK=/run/user/1000/ssh-agent.socket ssh-add ~/.ssh/id_ed25519

run_onchange_after_20-ssh-agent.sh enables Arch’s socket-activated agent
(/usr/lib/systemd/user/ssh-agent.socket). AddKeysToAgent in ~/.ssh/config
loads the key on first use, so the passphrase is typed once per boot rather
than per connection.

ssh -T git@github.com                         # expect Hi <user>!

Note chezmoi init ppalms/dotfiles expands to an HTTPS URL. Use the explicit
SSH form.

Clone and apply

chezmoi init --apply git@github.com:ppalms/dotfiles.git --branch v2

Prompts for name, email and font size; pass --promptString for unattended
runs. Answers persist to ~/.config/chezmoi/chezmoi.toml, so re-running init
does not re-ask — which also means changing a default in .chezmoi.toml.tmpl
never reaches a machine that already answered. Edit that file directly to retune
a size. The font family is deliberately not a prompt for this reason; it lives
in .chezmoidata/font.yaml and propagates on a plain apply.

The apply installs the packages.yaml set via
run_onchange_before_10-packages.sh (sudo may prompt) and, on native Linux,
enables the socket-activated ssh-agent via run_onchange_after_20-ssh-agent.sh.

Linger

sudo loginctl enable-linger $USER

Without it /run/user/1000 is never created, XDG_RUNTIME_DIR is missing, and
the ssh-agent socket fails in a way that looks like an SSH problem while SSH
itself works fine.

Neovim first run

chezmoi apply does not provision Neovim. First launch clones nvim-treesitter
and compiles its parsers. Order matters: nvim-pack-lock.json must be on disk
before nvim first runs, which is why this comes after apply. Launch nvim
first and you silently get main HEAD instead of the pinned revision.

nvim

Explicit packages

sudo pacman -D --asexplicit diffutils

diffutils is already installed as an mkinitcpio dependency, so
pacman -S --needed skips it and it stays flagged as a dependency — a later
pacman -Rns could sweep it up.

Desktop

sudo pacman -S hyprland xdg-desktop-portal-hyprland xdg-desktop-portal \
  waybar wofi hyprpaper hyprlock hypridle swaync grim slurp wl-clipboard \
  brightnessctl playerctl network-manager-applet polkit-kde-agent \
  qt6-wayland pipewire pipewire-pulse wireplumber blueman \
  alacritty ttf-ibmplex-mono-nerd \
  mesa vulkan-radeon libva-mesa-driver mesa-vdpau

The Hyprland config is managed in this repo (dot_config/hypr/) and was already
applied by the bootstrap step. The Hyprland project no longer recommends uwsm (Universal Wayland Session Manager),
so start it from the TTY:

start-hyprland

The config is Lua, not hyprlang (deprecated). Grow the config using
chezmoi edit ~/.config/hypr/hyprland.lua; add waybar/hyprpaper when the
bare config actually annoys you. Two consequences worth knowing before
debugging:

The Gruvbox palette is centralized in .chezmoidata/gruvbox.yaml and renders
into alacritty, tmux and hyprland — edit it there, never in the configs.

Keyboard

Keyboard: type-test it. hyprctl will lie to you. The layout is Colemak-DH
from a custom XKB symbols file (dot_config/xkb/symbols/colemak_dhm, managed by chezmoi),
selected with kb_layout = "colemak_dhm"not kb_variant, which cannot
reference a standalone file at all. Both hyprctl configerrors and hyprctl getoption input:kb_layout report success while the layout is silently wrong, so
typing is the only real check.

kb_options = compose:caps makes CapsLock a Compose key. The sequences live in
~/.XCompose, restored by chezmoi apply. Test using CapsLock, then space,
then n — because a missing or empty file fails silently:
CapsLock starts a sequence with nothing to complete it and no error appears.

Verify

Confirm the verification commands exist before trusting their output — a missing
binary does not always fail loudly:

m=""; for c in diff cmp sha256sum jq awk; do command -v "$c" >/dev/null || m="$m $c"; done
[ -z "$m" ] && echo probes-ok || echo "MISSING:$m -- fix before trusting anything below"

Do not collapse that loop to command -v diff cmp ... — Bash exits 0 if any
one of several names resolves.

Then:

ssh-add -l                                     # 1 (per-machine key)
ssh -T git@github.com                         # Hi <user>!
tmux -f ~/.config/tmux/tmux.conf start-server \; display -p '#{prefix}' \; kill-server
nvim --version | head -1                      # 0.12.x
ls ~/.local/share/nvim/site/parser | wc -l    # 15
[ -d /run/user/1000 ] && echo linger-ok
opencode --version                            # from pacman, /usr/bin/opencode
chezmoi diff                                  # silent

The lockfile actually drove the install — the parser count above passes either
way, so this is the assertion that distinguishes “the committed lockfile
worked” from “landed on main HEAD and got lucky”:

want=$(jq -r '.plugins["nvim-treesitter"].rev' ~/.config/nvim/nvim-pack-lock.json)
got=$(git -C ~/.local/share/nvim/site/pack/core/opt/nvim-treesitter rev-parse HEAD)
[ "$want" = "$got" ] && echo "lockfile-ok $got" || echo "MISMATCH want=$want got=$got"

opencode is the check that packages.yaml provisions a tool end-to-end. If it
is missing, the package install did not complete — not a reason to reach for the
upstream curl installer. Claude Code, by contrast, is expected to be absent;
it is unpackaged and installs by hand: ./scripts/install-claude-code.sh.

Also verify native-specific items:

systemctl --user is-enabled ssh-agent.socket   # enabled
cat /sys/firmware/efi/fw_platform_size         # 64

First commit

Commits sign automatically via the SSH agent — git log -1 --format='%G?' should
return G. Register the key with GitHub as a Signing Key too: GitHub treats
authentication and signing keys separately, and the same key must be added under
both types or commits show Unverified. Every commit comes from WSL or the
native laptop; there is no Windows clone to commit from.

Before the first commit, register this machine’s key in allowed_signers so its
own commits verify locally (git log --show-signature would otherwise report
“No principal matched”). Replace the existing Arch line in
private_dot_ssh/allowed_signers.tmpl with:

{{ .email }} $(cat ~/.ssh/id_ed25519.pub)

(run that as a shell expansion, or just copy the output of
echo "{{ .email }} $(cat ~/.ssh/id_ed25519.pub)" into the file). This is a
per-reinstall step — every new key needs a new line. Then make the first signed
commit + push from the laptop.