omaterm.org/install now installs the AI agents itself (node/opencode/ claude-code/codex/gemini are in its mise.packages) and its installer ends with `exec bash -l`, which re-sources ~/.bash_profile. - Remove our now-redundant "Install AI agents via mise" prompt and its stale comment (omaterm provides them). - Guard against the re-sourced .bash_profile re-firing the welcome: the firstrun script removes itself up-front, so the one-shot stays one-shot. - Reorder so OMATERM runs last (Once + Omarchy-Send first), since its `exec bash -l` hijacks the shell and drops the user into their session. - Update welcome banner + in-script docs to list the bundled AI agents and the new ordering. Also gitignore build.log. Verified: pty-backed logic harness (ordering, one-shot, no recursion) and a full VM boot of the rebuilt ISO (welcome + Once-first + clean no-recursion shell on omaterm's failure path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1262 lines
53 KiB
Bash
Executable file
1262 lines
53 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# omalocal.sh — one script: turn a stock Arch ISO into a headless Arch
|
|
# server installer ISO that, on first login, offers to bootstrap the OMATERM
|
|
# toolkit (omaterm.org) + Once. Ready for Ventoy or dd.
|
|
#
|
|
# NOTE: this is the *ISO builder*, not the OMATERM toolkit itself. It bundles
|
|
# and installs the official OMATERM (https://omaterm.org); it is not affiliated
|
|
# with it.
|
|
#
|
|
# Usage:
|
|
# 1. Drop a stock Arch ISO (https://archlinux.org/download/, file starts
|
|
# with 'archlinux-') into the same folder as this script.
|
|
# 2. ./omalocal.sh
|
|
# 3. Output: omalocal-arch-YYYYMMDD.iso (Ventoy-compatible, dd-bootable).
|
|
#
|
|
# Dependencies: xorriso, squashfs-tools, git, sha512sum, sudo.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
WORK="$SCRIPT_DIR/work"
|
|
INVOKING_USER="${SUDO_USER:-$(id -un)}"
|
|
INVOKING_GROUP="$(id -gn "$INVOKING_USER")"
|
|
OUT_ISO="$SCRIPT_DIR/omalocal-arch-$(date +%Y%m%d).iso"
|
|
|
|
# Clean up the work dir on any exit (success, error, or Ctrl-C). 2>/dev/null
|
|
# is in case $WORK never got created (e.g. we exited before mkdir).
|
|
trap 'sudo rm -rf "$WORK" 2>/dev/null || true' EXIT
|
|
|
|
# ===========================================================================
|
|
# write_installer — emit the live-ISO installer to the given path.
|
|
# This function holds the script that runs on the *target* machine inside
|
|
# the live ISO (auto-launched on tty1). It is intentionally embedded here so
|
|
# this file is the only artifact needed to rebuild the installer ISO.
|
|
# ===========================================================================
|
|
write_installer() {
|
|
cat > "$1" <<'__INSTALLER_PAYLOAD__'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
MARKER="/tmp/.installer-ran"
|
|
if [ -f "$MARKER" ]; then
|
|
echo
|
|
echo " Installer already ran this session."
|
|
echo " To retry: rm $MARKER && bash $0"
|
|
echo
|
|
exec /bin/bash
|
|
fi
|
|
touch "$MARKER"
|
|
|
|
# Mirror stdout/stderr to /tmp/installer.log. If the script aborts, the user
|
|
# can switch to tty2, log in as root, and `cat /tmp/installer.log` to see the
|
|
# last "==> ..." marker reached and any error printed by the failing command.
|
|
exec > >(tee /tmp/installer.log) 2>&1
|
|
|
|
if [ ! -d /sys/firmware/efi ]; then
|
|
echo "ERROR: not booted in UEFI mode. UEFI-only installer." >&2
|
|
exec /bin/bash
|
|
fi
|
|
|
|
clear
|
|
cat <<'BANNER'
|
|
|
|
=============================================
|
|
=== Arch Linux Installer ===
|
|
=============================================
|
|
|
|
BANNER
|
|
|
|
# ---------------------------------------------------------------- network ----
|
|
WIFI_SSID=""
|
|
WIFI_PSK=""
|
|
|
|
# Use an HTTP(S) probe, not ICMP: networks that route fine but block outbound
|
|
# ping (some hotel/corporate/captive setups) would otherwise loop here forever
|
|
# even though pacstrap would work. curl ships on the archiso live image.
|
|
have_net() { curl -fsS --max-time 4 https://archlinux.org/ -o /dev/null 2>/dev/null; }
|
|
|
|
setup_wired() {
|
|
echo " Bringing up wired interfaces and waiting for DHCP..."
|
|
for ifpath in /sys/class/net/*; do
|
|
iface=$(basename "$ifpath")
|
|
case "$iface" in
|
|
lo|wl*) continue ;;
|
|
esac
|
|
ip link set "$iface" up 2>/dev/null || true
|
|
done
|
|
systemctl restart systemd-networkd 2>/dev/null || true
|
|
for _ in $(seq 1 20); do
|
|
have_net && return 0
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
setup_wifi() {
|
|
local dev
|
|
dev=$(iwctl device list 2>/dev/null | awk '/station/ {print $2; exit}')
|
|
if [ -z "$dev" ]; then
|
|
echo " No Wi-Fi device found."
|
|
return 1
|
|
fi
|
|
echo " Wi-Fi device: $dev"
|
|
rfkill unblock wifi 2>/dev/null || true
|
|
ip link set "$dev" up 2>/dev/null || true
|
|
iwctl station "$dev" scan 2>/dev/null || true
|
|
echo " Scanning (3s)..."
|
|
sleep 3
|
|
echo
|
|
echo " Visible networks:"
|
|
iwctl station "$dev" get-networks 2>/dev/null | sed 's/^/ /' | head -25
|
|
echo
|
|
read -rp " SSID: " ssid </dev/tty
|
|
read -rp " Open network (no password)? [y/N] " is_open </dev/tty
|
|
if [[ "$is_open" =~ ^[Yy] ]]; then
|
|
wpass=""
|
|
if ! iwctl station "$dev" connect "$ssid"; then
|
|
echo " iwctl connect failed."
|
|
return 1
|
|
fi
|
|
else
|
|
read -rsp " Wi-Fi password: " wpass </dev/tty; echo
|
|
if ! iwctl --passphrase "$wpass" station "$dev" connect "$ssid"; then
|
|
echo " iwctl connect failed."
|
|
return 1
|
|
fi
|
|
fi
|
|
echo " Waiting up to 20s for connection..."
|
|
for _ in $(seq 1 20); do
|
|
if have_net; then
|
|
WIFI_SSID="$ssid"
|
|
WIFI_PSK="$wpass"
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
echo "==> Checking network..."
|
|
if have_net; then
|
|
echo " Connected."
|
|
else
|
|
while ! have_net; do
|
|
echo
|
|
echo " No internet. Pick one:"
|
|
echo " 1) Wired (try DHCP)"
|
|
echo " 2) Wi-Fi (iwctl wrapper)"
|
|
echo " 3) Open shell to configure manually (type 'exit' to return)"
|
|
read -rp " [1-3]: " N </dev/tty
|
|
case "$N" in
|
|
1) setup_wired || echo " Wired didn't come up." ;;
|
|
2) setup_wifi || echo " Wi-Fi didn't come up." ;;
|
|
3) bash ;;
|
|
*) echo " Invalid." ;;
|
|
esac
|
|
done
|
|
echo " Connected."
|
|
fi
|
|
|
|
# ---------------------------------------------------------------- prompts ----
|
|
while true; do
|
|
read -rp " Hostname [arch]: " HOSTNAME </dev/tty
|
|
HOSTNAME="${HOSTNAME:-arch}"
|
|
[[ "$HOSTNAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] && break
|
|
echo " Invalid (RFC 1123: letters/digits/hyphen, no leading hyphen, max 63 chars)."
|
|
done
|
|
|
|
while true; do
|
|
read -rp " Username: " USERNAME </dev/tty
|
|
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] && break
|
|
echo " Invalid (lowercase, starts a-z or _, max 31 chars)."
|
|
done
|
|
|
|
while true; do
|
|
read -rsp " Password for $USERNAME (also used for root): " PW1 </dev/tty; echo
|
|
read -rsp " Confirm password: " PW2 </dev/tty; echo
|
|
if [ -z "$PW1" ]; then echo " Empty — try again."; continue; fi
|
|
if [ "$PW1" != "$PW2" ]; then echo " Mismatch — try again."; continue; fi
|
|
break
|
|
done
|
|
USER_PW="$PW1"
|
|
|
|
echo
|
|
echo " Available disks:"
|
|
mapfile -t DISKS < <(lsblk -d -n -p -o NAME,SIZE,MODEL | grep -Ev '/dev/(loop|sr|zram|fd)')
|
|
if [ "${#DISKS[@]}" -eq 0 ]; then
|
|
echo " No disks found. Dropping to shell."
|
|
exec /bin/bash
|
|
fi
|
|
for i in "${!DISKS[@]}"; do
|
|
printf " %d) %s\n" $((i+1)) "${DISKS[$i]}"
|
|
done
|
|
echo
|
|
while true; do
|
|
read -rp " Pick a disk [1-${#DISKS[@]}]: " N </dev/tty
|
|
[[ "$N" =~ ^[0-9]+$ ]] && [ "$N" -ge 1 ] && [ "$N" -le "${#DISKS[@]}" ] && break
|
|
done
|
|
DISK=$(awk '{print $1}' <<<"${DISKS[$((N-1))]}")
|
|
|
|
echo
|
|
echo " About to ERASE EVERYTHING on: $DISK"
|
|
read -rp " Type the disk path exactly to confirm: " CONFIRM </dev/tty
|
|
if [ "$CONFIRM" != "$DISK" ]; then
|
|
echo " Mismatch — aborting."
|
|
exec /bin/bash
|
|
fi
|
|
|
|
if [[ "$DISK" =~ nvme|mmcblk ]]; then
|
|
EFI_PART="${DISK}p1"; ROOT_PART="${DISK}p2"
|
|
else
|
|
EFI_PART="${DISK}1"; ROOT_PART="${DISK}2"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------- install ----
|
|
echo
|
|
echo "==> Wiping $DISK..."
|
|
swapoff -a || true
|
|
umount -R /mnt 2>/dev/null || true
|
|
wipefs -af "$DISK"
|
|
sgdisk --zap-all "$DISK"
|
|
|
|
echo "==> Partitioning (512M EFI + rest ext4)..."
|
|
sgdisk -n1:0:+512M -t1:ef00 -c1:EFI "$DISK"
|
|
sgdisk -n2:0:0 -t2:8300 -c2:ROOT "$DISK"
|
|
partprobe "$DISK"
|
|
udevadm settle
|
|
|
|
echo "==> Formatting..."
|
|
mkfs.fat -F32 -n EFI "$EFI_PART"
|
|
mkfs.ext4 -F -L ROOT "$ROOT_PART"
|
|
|
|
echo "==> Mounting..."
|
|
mount "$ROOT_PART" /mnt
|
|
mkdir -p /mnt/boot
|
|
mount "$EFI_PART" /mnt/boot
|
|
|
|
echo "==> Pacstrap (this is the slow bit)..."
|
|
pacstrap -K /mnt \
|
|
base linux linux-firmware intel-ucode amd-ucode \
|
|
networkmanager openssh sudo git curl vim less \
|
|
base-devel ufw avahi nss-mdns \
|
|
kitty-terminfo foot-terminfo ghostty-terminfo rxvt-unicode-terminfo
|
|
|
|
echo "==> Generating fstab..."
|
|
genfstab -U /mnt >> /mnt/etc/fstab
|
|
|
|
echo "==> Configuring base system in chroot..."
|
|
arch-chroot /mnt /bin/bash <<'CHROOT'
|
|
set -e
|
|
ln -sf /usr/share/zoneinfo/UTC /etc/localtime
|
|
hwclock --systohc
|
|
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
|
|
echo 'KEYMAP=us' > /etc/vconsole.conf
|
|
sed -i 's/^# %wheel ALL=(ALL:ALL) ALL/%wheel ALL=(ALL:ALL) ALL/' /etc/sudoers
|
|
systemctl enable NetworkManager.service
|
|
systemctl enable sshd.service
|
|
# Avahi/mDNS so the box is reachable as <hostname>.local on the LAN (and can
|
|
# resolve other .local hosts). Insert mdns_minimal ahead of 'resolve' on the
|
|
# hosts: NSS line per the Arch wiki recommendation.
|
|
systemctl enable avahi-daemon.service
|
|
sed -i '/^hosts:/ s/\bresolve\b/mdns_minimal [NOTFOUND=return] resolve/' /etc/nsswitch.conf
|
|
bootctl install
|
|
cat > /boot/loader/loader.conf <<LOADER
|
|
default arch.conf
|
|
timeout 3
|
|
console-mode max
|
|
editor no
|
|
LOADER
|
|
CHROOT
|
|
|
|
# Firewall — mirror Omarchy's first-run/firewall.sh defaults. Kept OUTSIDE the
|
|
# strict chroot block: ufw in a chroot can't apply rules to netfilter and
|
|
# returns non-zero on some versions, which would abort the install under
|
|
# 'set -e'. We tolerate each rule write individually, then set ENABLED=yes
|
|
# directly in /etc/ufw/ufw.conf and enable the service so it applies at boot.
|
|
echo "==> Configuring firewall (best-effort writes inside chroot)..."
|
|
arch-chroot /mnt ufw default deny incoming || true
|
|
arch-chroot /mnt ufw default allow outgoing || true
|
|
arch-chroot /mnt ufw allow 22/tcp || true
|
|
arch-chroot /mnt ufw allow 80/tcp || true
|
|
arch-chroot /mnt ufw allow 443/tcp || true
|
|
arch-chroot /mnt ufw allow 5353/udp || true
|
|
arch-chroot /mnt ufw allow 53317/tcp || true
|
|
arch-chroot /mnt ufw allow 53317/udp || true
|
|
sed -i 's/^ENABLED=no/ENABLED=yes/' /mnt/etc/ufw/ufw.conf
|
|
arch-chroot /mnt systemctl enable ufw.service
|
|
|
|
echo "==> Writing hostname / hosts..."
|
|
echo "$HOSTNAME" > /mnt/etc/hostname
|
|
cat > /mnt/etc/hosts <<HOSTS
|
|
127.0.0.1 localhost
|
|
::1 localhost
|
|
127.0.1.1 $HOSTNAME.localdomain $HOSTNAME
|
|
HOSTS
|
|
|
|
echo "==> Creating user $USERNAME..."
|
|
arch-chroot /mnt useradd -m -G wheel -s /bin/bash "$USERNAME"
|
|
printf 'root:%s\n%s:%s\n' "$USER_PW" "$USERNAME" "$USER_PW" | arch-chroot /mnt chpasswd
|
|
|
|
# ----------------------------------------------------------- ssh handoff ----
|
|
if [ -f /root/master.pub ]; then
|
|
echo "==> Installing master SSH key for $USERNAME..."
|
|
install -m 700 -d "/mnt/home/$USERNAME/.ssh"
|
|
install -m 600 /root/master.pub "/mnt/home/$USERNAME/.ssh/authorized_keys"
|
|
arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "/home/$USERNAME/.ssh"
|
|
|
|
echo "==> Locking sshd to key-only auth..."
|
|
install -m 755 -d /mnt/etc/ssh/sshd_config.d
|
|
cat > /mnt/etc/ssh/sshd_config.d/10-key-only.conf <<'SSHD'
|
|
PasswordAuthentication no
|
|
KbdInteractiveAuthentication no
|
|
PermitRootLogin no
|
|
SSHD
|
|
else
|
|
echo " (no /root/master.pub on the ISO — skipping SSH key handoff)"
|
|
fi
|
|
|
|
# ---------------------------------------------- ~/.local/bin on PATH --------
|
|
# Put ~/.local/bin onto every login shell's PATH so user-installed binaries
|
|
# (omarchy-send, mise, pipx, future cargo installs, etc.) are runnable without
|
|
# manual PATH edits or a relog after OMATERM. /etc/profile.d/*.sh is sourced by
|
|
# /etc/profile for any login shell, before user dotfiles, so this works even
|
|
# during the firstrun script (which runs inside the initial login shell).
|
|
echo "==> Adding ~/.local/bin to PATH for all login shells..."
|
|
install -d -m 755 /mnt/etc/profile.d
|
|
cat > /mnt/etc/profile.d/local-bin.sh <<'PROFILE_LOCAL_BIN'
|
|
# Add ~/.local/bin to PATH for login shells (idempotent).
|
|
case ":$PATH:" in
|
|
*":$HOME/.local/bin:"*) ;;
|
|
*) [ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH" ;;
|
|
esac
|
|
PROFILE_LOCAL_BIN
|
|
chmod 644 /mnt/etc/profile.d/local-bin.sh
|
|
|
|
# ---------------------------------------------- mise shims on PATH ----------
|
|
# Put mise's shims dir on PATH for every login shell so globally-installed mise
|
|
# tools (claude, codex, opencode, gemini, node, ...) are runnable from the
|
|
# command line — including non-interactive `ssh host <cmd>` runs — without
|
|
# depending on `mise activate` being wired into the user's dotfiles. The dir is
|
|
# created by mise the first time a tool is installed; the [ -d ] guard means
|
|
# this is a no-op until then, then takes effect on the next login shell.
|
|
echo "==> Adding mise shims to PATH for all login shells..."
|
|
cat > /mnt/etc/profile.d/mise-shims.sh <<'PROFILE_MISE_SHIMS'
|
|
# Add mise's shims dir to PATH for login shells (idempotent).
|
|
mise_shims="${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/mise}/shims"
|
|
case ":$PATH:" in
|
|
*":$mise_shims:"*) ;;
|
|
*) [ -d "$mise_shims" ] && PATH="$mise_shims:$PATH" ;;
|
|
esac
|
|
unset mise_shims
|
|
PROFILE_MISE_SHIMS
|
|
chmod 644 /mnt/etc/profile.d/mise-shims.sh
|
|
|
|
# ---------------------------------------------------- omalocal first-run ------
|
|
# Offer to install omaterm on the user's first *interactive* login. This box is
|
|
# headless + key-only SSH, so "first boot" really means "first ssh login" — a
|
|
# .bash_profile hook is the only place we can take interactive stdin. The hook
|
|
# is guarded by [ -t 0 ] so non-interactive `ssh host <cmd>` runs never consume
|
|
# it, and it self-removes after running so the user is asked exactly once.
|
|
echo "==> Installing omalocal first-login prompt for $USERNAME..."
|
|
cat > "/mnt/home/$USERNAME/.omalocal-firstrun.sh" <<'OMALOCAL_FIRSTRUN'
|
|
#!/usr/bin/env bash
|
|
# Shown once, on first interactive login. Remove ourselves up-front: OMATERM's
|
|
# installer ends with `exec bash -l`, which re-sources ~/.bash_profile. If this
|
|
# file still existed at that point the first-run hook would re-fire and we'd
|
|
# recurse into the welcome prompt. Deleting it now (rather than letting the
|
|
# .bash_profile hook delete it after we return) makes the prompt strictly once.
|
|
rm -f "$HOME/.omalocal-firstrun.sh"
|
|
|
|
clear
|
|
cat <<'BANNER'
|
|
|
|
============================================================
|
|
=== Welcome to OMATERM + Once ===
|
|
============================================================
|
|
|
|
This is a fresh Arch Linux server installed by omalocal.
|
|
|
|
Setting up the server installs two things:
|
|
|
|
OMATERM — a terminal-first toolkit: starship, neovim, tmux,
|
|
mise, docker, lazygit, yay, plus AI agents
|
|
(claude-code, codex, opencode, gemini), etc.
|
|
Once — Basecamp's self-hosted app deployment platform.
|
|
|
|
Optional extra:
|
|
|
|
Omarchy-Send — terminal-UI LocalSend-compatible file transfer
|
|
and messaging (the firewall already opens 53317).
|
|
|
|
Installers (run any of these later if you skip):
|
|
curl -fsSL https://omaterm.org/install | bash
|
|
curl https://get.once.com | ONCE_INTERACTIVE=false sh
|
|
curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash
|
|
|
|
BANNER
|
|
|
|
setup_server=0
|
|
read -rp " Set up this server (OMATERM + Once) now? [Y/n] " ans </dev/tty
|
|
case "$ans" in
|
|
[Nn]*)
|
|
echo
|
|
echo " Skipped. To set up later, run:"
|
|
echo " curl https://get.once.com | ONCE_INTERACTIVE=false sh"
|
|
echo " curl -fsSL https://omaterm.org/install | bash"
|
|
echo
|
|
;;
|
|
*)
|
|
setup_server=1
|
|
# Install Once FIRST. OMATERM is deferred to the very end of this script
|
|
# because its installer finishes with `exec bash -l`, hijacking this shell
|
|
# and dropping the user straight into their new tmux session — nothing after
|
|
# that call would ever run. So Once (and Omarchy-Send below) go first.
|
|
echo
|
|
echo " [1/2] Installing Once..."
|
|
if curl https://get.once.com | ONCE_INTERACTIVE=false sh; then
|
|
echo " Once installed."
|
|
else
|
|
echo " Once install did not complete — retry later with:"
|
|
echo " curl https://get.once.com | ONCE_INTERACTIVE=false sh"
|
|
fi
|
|
|
|
# Deploying Once apps reachable on the LAN: use once-add. The Once TUI
|
|
# can't do it here (static binary can't resolve .local during its verify,
|
|
# and it forces TLS). once-add does the /etc/hosts + --disable-tls dance;
|
|
# once-mdns-sync then publishes <name>.local to the LAN.
|
|
echo
|
|
echo " Deploying web apps reachable on your network:"
|
|
echo " Use once-add (NOT the Once TUI for LAN apps):"
|
|
echo " once-add # interactive wizard"
|
|
echo " once-add book ghcr.io/basecamp/writebook # non-interactive"
|
|
echo " -> reachable at http://book.local from any machine on the LAN."
|
|
echo " Use a single-label name; it serves over http:// (no TLS on a LAN box)."
|
|
;;
|
|
esac
|
|
|
|
# Omarchy-Send is independent of OMATERM/Once — offered separately so a user
|
|
# can take it without the rest, or skip it after taking Once.
|
|
echo
|
|
read -rp " Install Omarchy-Send (TUI file transfer / messaging) now? [Y/n] " ans </dev/tty
|
|
case "$ans" in
|
|
[Nn]*)
|
|
echo
|
|
echo " Skipped. To install later, run:"
|
|
echo " curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash"
|
|
echo
|
|
;;
|
|
*)
|
|
echo
|
|
echo " Installing Omarchy-Send..."
|
|
if curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash; then
|
|
echo " Omarchy-Send installed."
|
|
echo " Run it in an interactive shell: omarchy-send"
|
|
echo " (Needs a real TTY — over SSH use 'ssh -tt' for scripted runs.)"
|
|
else
|
|
echo " Omarchy-Send install did not complete — retry later with:"
|
|
echo " curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash"
|
|
fi
|
|
echo
|
|
;;
|
|
esac
|
|
|
|
# OMATERM goes LAST. Its installer (omaterm.org/install) ends with `exec bash -l`,
|
|
# which replaces this shell and drops the user into their configured tmux session
|
|
# — so nothing after this call would run, which is exactly why it's the closer.
|
|
# OMATERM now also installs the AI agents itself (node, opencode, claude-code,
|
|
# codex, gemini are in its mise.packages), so there's no separate agent step.
|
|
if [ "$setup_server" = 1 ]; then
|
|
echo
|
|
echo " [2/2] Installing OMATERM (toolkit + AI agents: claude-code, codex,"
|
|
echo " opencode, gemini)... when it finishes you'll be dropped straight"
|
|
echo " into your new shell."
|
|
echo
|
|
curl -fsSL https://omaterm.org/install | bash || {
|
|
echo " OMATERM install did not complete — retry later with:"
|
|
echo " curl -fsSL https://omaterm.org/install | bash"
|
|
}
|
|
fi
|
|
OMALOCAL_FIRSTRUN
|
|
|
|
# Append the one-shot hook. Only fires on an interactive login shell, and only
|
|
# while the firstrun script still exists; both conditions clear after first use.
|
|
cat >> "/mnt/home/$USERNAME/.bash_profile" <<'OMALOCAL_PROFILE_HOOK'
|
|
|
|
# --- omalocal first-run prompt (self-removing) ---
|
|
if [ -t 0 ] && [ -f "$HOME/.omalocal-firstrun.sh" ]; then
|
|
bash "$HOME/.omalocal-firstrun.sh"
|
|
rm -f "$HOME/.omalocal-firstrun.sh"
|
|
fi
|
|
# --- end omalocal first-run prompt ---
|
|
OMALOCAL_PROFILE_HOOK
|
|
|
|
arch-chroot /mnt chown "$USERNAME:$USERNAME" \
|
|
"/home/$USERNAME/.omalocal-firstrun.sh" "/home/$USERNAME/.bash_profile"
|
|
arch-chroot /mnt chmod 755 "/home/$USERNAME/.omalocal-firstrun.sh"
|
|
|
|
# ------------------------------------------------ once-mdns-sync daemon ------
|
|
# Publishes <name>.local on the LAN for each Once app (apps are deployed via
|
|
# once-add as single-label <name>.local). mDNS has no wildcard, so the
|
|
# box must announce each name; this daemon watches the *.local hostnames Once is
|
|
# actually serving (from each app container's `once` label) and keeps a matching
|
|
# set of mDNS aliases published. Enabled below; idles until docker + apps exist.
|
|
echo "==> Installing once-mdns-sync (auto *.local mDNS for Once apps)..."
|
|
install -d -m 755 /mnt/usr/local/bin
|
|
cat > /mnt/usr/local/bin/once-mdns-sync <<'ONCE_MDNS_SYNC'
|
|
#!/usr/bin/env bash
|
|
# Publish an mDNS alias for every *.local hostname Once is currently serving,
|
|
# so apps deployed as <name>.local are reachable from
|
|
# other machines on the LAN without any per-app setup. Reconciles on start and
|
|
# every RECONCILE_SECS; one `avahi-publish` child per announced name.
|
|
set -uo pipefail
|
|
|
|
RECONCILE_SECS="${RECONCILE_SECS:-20}"
|
|
self="$(uname -n).local" # uname is in coreutils; `hostname` isn't in base Arch
|
|
declare -A PUB # hostname -> avahi-publish PID
|
|
PUB_IP="" # address the current publishers were started with
|
|
|
|
current_ip() {
|
|
local ip
|
|
ip=$(ip -4 route get 1.1.1.1 2>/dev/null \
|
|
| awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
|
|
if [ -z "$ip" ]; then
|
|
# No default route (isolated LAN): first global-scope IPv4 that isn't a
|
|
# container (docker/bridge/veth) or VPN (tailscale/wg) interface, so we
|
|
# announce a real LAN address rather than a container or overlay IP.
|
|
ip=$(ip -4 -o addr show scope global 2>/dev/null \
|
|
| awk '$2 !~ /^(docker|br-|veth|tailscale|wg)/ {sub(/\/.*/, "", $4); print $4; exit}')
|
|
fi
|
|
printf '%s' "$ip"
|
|
}
|
|
|
|
# The "host" field of each running app container's `once` label, kept only when
|
|
# it's a *.local name. Once stores ApplicationSettings as JSON in the `once`
|
|
# label (basecamp/once: internal/docker/application.go sets labelKey "once" on
|
|
# the app container; application_settings.go marshals Host as json:"host"), so
|
|
# we pull that one field rather than scanning the whole blob — no false matches
|
|
# from image names, env values, etc.
|
|
wanted_hosts() {
|
|
local ids
|
|
ids=$(docker ps -q 2>/dev/null) || return 0
|
|
[ -n "$ids" ] || return 0
|
|
# shellcheck disable=SC2086
|
|
docker inspect --format '{{ index .Config.Labels "once" }}' $ids 2>/dev/null \
|
|
| grep -oP '"host"\s*:\s*"\K[^"]+' \
|
|
| grep -iE '\.local$' \
|
|
| tr '[:upper:]' '[:lower:]' \
|
|
| sort -u
|
|
}
|
|
|
|
reconcile() {
|
|
local ip; ip="$(current_ip)"
|
|
[ -n "$ip" ] || return 0
|
|
|
|
local h
|
|
# If the box's address changed, drop every publisher so they re-register
|
|
# against the new IP on the pass below (no manual restart needed on DHCP).
|
|
if [ "$ip" != "$PUB_IP" ]; then
|
|
for h in "${!PUB[@]}"; do
|
|
kill "${PUB[$h]}" 2>/dev/null || true
|
|
unset 'PUB[$h]'
|
|
done
|
|
PUB_IP="$ip"
|
|
fi
|
|
|
|
declare -A want=()
|
|
while IFS= read -r h; do
|
|
[ -z "$h" ] && continue
|
|
[ "$h" = "$self" ] && continue # avahi-daemon already owns our own name
|
|
want["$h"]=1
|
|
done < <(wanted_hosts)
|
|
|
|
# Drop publishers that are no longer wanted or whose process has died.
|
|
for h in "${!PUB[@]}"; do
|
|
if [ -z "${want[$h]:-}" ] || ! kill -0 "${PUB[$h]}" 2>/dev/null; then
|
|
kill "${PUB[$h]}" 2>/dev/null || true
|
|
unset 'PUB[$h]'
|
|
fi
|
|
done
|
|
|
|
# Start publishers for newly wanted names.
|
|
for h in "${!want[@]}"; do
|
|
if [ -z "${PUB[$h]:-}" ]; then
|
|
avahi-publish -a -R "$h" "$ip" &
|
|
PUB["$h"]=$!
|
|
fi
|
|
done
|
|
}
|
|
|
|
cleanup() { local p; for p in "${PUB[@]}"; do kill "$p" 2>/dev/null || true; done; exit 0; }
|
|
trap cleanup TERM INT
|
|
|
|
while :; do
|
|
reconcile
|
|
sleep "$RECONCILE_SECS"
|
|
done
|
|
ONCE_MDNS_SYNC
|
|
chmod 755 /mnt/usr/local/bin/once-mdns-sync
|
|
|
|
cat > /mnt/etc/systemd/system/once-mdns-sync.service <<'ONCE_MDNS_UNIT'
|
|
[Unit]
|
|
Description=Auto-publish mDNS aliases for *.local hostnames served by Once
|
|
Requires=avahi-daemon.service
|
|
After=avahi-daemon.service docker.service network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
ExecStart=/usr/local/bin/once-mdns-sync
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
ONCE_MDNS_UNIT
|
|
arch-chroot /mnt systemctl enable once-mdns-sync.service
|
|
|
|
# ------------------------------------------------ once-add binary ------------
|
|
# Bubble Tea TUI + non-interactive CLI for deploying a Once app reachable on the
|
|
# LAN as <name>.local. Works around two hard constraints of Once on a private
|
|
# box (both verified):
|
|
# 1. The `once` binary is statically linked (pure-Go resolver), so its
|
|
# post-deploy HTTP verification can't resolve mDNS/.local names — only
|
|
# /etc/hosts or real DNS. once-add adds the /etc/hosts entry so verify
|
|
# resolves the host to loopback and passes.
|
|
# 2. The TUI install forces TLS on and can't get a Let's Encrypt cert for a
|
|
# private .local host, so the app is rolled back. once-add runs the deploy
|
|
# with --disable-tls; once-mdns-sync then publishes <name>.local on the LAN.
|
|
# Source + releases: https://github.com/28allday/once-add
|
|
echo "==> Installing once-add (deploy a Once app as <name>.local)..."
|
|
ONCE_ADD_URL="https://github.com/28allday/once-add/releases/latest/download/once-add"
|
|
if curl -fsSL "$ONCE_ADD_URL" -o /mnt/usr/local/bin/once-add; then
|
|
chmod 755 /mnt/usr/local/bin/once-add
|
|
echo " once-add installed."
|
|
else
|
|
rm -f /mnt/usr/local/bin/once-add
|
|
echo " !! could not download once-add (network?) — install it later with:"
|
|
echo " curl -fsSL $ONCE_ADD_URL \\"
|
|
echo " -o once-add && sudo install -m 755 once-add /usr/local/bin/once-add && rm once-add"
|
|
fi
|
|
|
|
echo "==> Pre-installing Claude skill (omalocal-server) for $USERNAME..."
|
|
install -d -m 755 "/mnt/home/$USERNAME/.claude/skills/omalocal-server"
|
|
cat > "/mnt/home/$USERNAME/.claude/skills/omalocal-server/SKILL.md" <<'SKILL_EOF'
|
|
---
|
|
name: omalocal-server
|
|
description: Context and shortcuts for an Arch Linux server provisioned by omalocal. Use when working on this host — deploying a web app with Once (especially CHOOSING THE APP'S DOMAIN so it's reachable on the LAN), installing services, managing the firewall (ufw), opening ports, debugging systemd units, checking what's listening, deploying docker, adding SSH keys, or anything that touches the box's baseline config.
|
|
---
|
|
|
|
# About this host
|
|
|
|
Installed by `omalocal.sh`, a single-script patcher that takes a stock Arch ISO and produces a headless install. The host was provisioned with:
|
|
|
|
- **Base packages**: `base linux linux-firmware intel-ucode amd-ucode networkmanager openssh sudo git curl vim less base-devel ufw avahi nss-mdns kitty-terminfo foot-terminfo ghostty-terminfo rxvt-unicode-terminfo`
|
|
- **SSH**: enabled, **key-only** (`/etc/ssh/sshd_config.d/10-key-only.conf` sets `PasswordAuthentication no`, `KbdInteractiveAuthentication no`, `PermitRootLogin no`). The master machine's public key was baked into `~/.ssh/authorized_keys` at install time.
|
|
- **Firewall**: ufw enabled with default deny incoming, allow outgoing. Open: `22/tcp` (SSH), `80/tcp` + `443/tcp` (web / Once), `5353/udp` (mDNS), `53317/tcp` + `53317/udp` (LocalSend).
|
|
- **mDNS**: avahi-daemon enabled and `mdns_minimal` wired into `/etc/nsswitch.conf`, so the box is reachable LAN-wide as `<hostname>.local` (try `ssh <user>@<hostname>.local`). The `once-mdns-sync.service` daemon (`/usr/local/bin/once-mdns-sync`) auto-publishes an mDNS alias for every single-label `*.local` host Once is serving — so apps deployed via `once-add` (as `<name>.local`) are reachable LAN-wide. See the "Deploying a Once app reachable on the LAN" recipe for the deploy side.
|
|
- **Init**: systemd. Networking via NetworkManager (`nmcli`).
|
|
- **Bootloader**: systemd-boot at `/boot/loader/`.
|
|
- **Login banner**: `/etc/issue` shows hostname + IPv4 + ssh hint above the console login prompt.
|
|
|
|
On the user's first interactive login a self-removing `~/.bash_profile` hook offers to set up the server. Once is installed first; OMATERM is installed last because its installer ends with `exec bash -l` and drops the user straight into their new shell:
|
|
|
|
- **Once** (https://once.com) — `curl https://get.once.com | ONCE_INTERACTIVE=false sh` — Basecamp's self-hosted app deployment platform.
|
|
- **Omaterm** (https://omaterm.org) — `curl -fsSL https://omaterm.org/install | bash` — terminal-first toolkit: starship, neovim, tmux, mise, docker, lazygit, yay, plus AI agents (claude-code, codex, opencode, gemini), etc. OMATERM installs the AI agents itself (they're in its `mise.packages`), so there's no separate agent step.
|
|
|
|
If the user declined, install them manually with those same two commands.
|
|
|
|
# Common admin recipes
|
|
|
|
## Open a port
|
|
```bash
|
|
sudo ufw allow 8080/tcp
|
|
sudo ufw status numbered # see rules with line numbers
|
|
sudo ufw delete 3 # remove rule 3
|
|
sudo ufw reload
|
|
```
|
|
|
|
## Add another SSH key
|
|
```bash
|
|
echo 'ssh-ed25519 AAAA... comment' >> ~/.ssh/authorized_keys
|
|
chmod 600 ~/.ssh/authorized_keys
|
|
```
|
|
|
|
## Re-enable password SSH (if locked out and want to remove key-only)
|
|
```bash
|
|
sudo rm /etc/ssh/sshd_config.d/10-key-only.conf
|
|
sudo systemctl restart sshd
|
|
```
|
|
|
|
## See what's listening
|
|
```bash
|
|
ss -tulpn # all listening sockets
|
|
sudo ss -tulpn | grep -E ':22|:80|:443' # specific ports
|
|
```
|
|
|
|
## Install and start docker
|
|
```bash
|
|
sudo pacman -S --noconfirm docker docker-compose
|
|
sudo systemctl enable --now docker
|
|
sudo usermod -aG docker "$USER" # log out and back in after
|
|
```
|
|
|
|
## Make docker play nicely with ufw (Omarchy's pattern)
|
|
```bash
|
|
sudo pacman -S --noconfirm ufw-docker
|
|
sudo ufw-docker install
|
|
sudo ufw reload
|
|
```
|
|
|
|
## Static IP via NetworkManager
|
|
```bash
|
|
nmcli con show # find connection name
|
|
nmcli con mod "<name>" ipv4.method manual ipv4.addresses 192.168.1.X/24
|
|
nmcli con mod "<name>" ipv4.gateway 192.168.1.1 ipv4.dns "1.1.1.1 9.9.9.9"
|
|
nmcli con up "<name>"
|
|
```
|
|
|
|
## Deploying a Once app reachable on the LAN
|
|
|
|
Use `once-add` — interactive wizard or one-liner:
|
|
|
|
```bash
|
|
once-add # interactive wizard (Add / Remove)
|
|
once-add book ghcr.io/basecamp/writebook # non-interactive add
|
|
once-add remove book # remove app + its /etc/hosts entry
|
|
# -> reachable at http://<name>.local from any LAN machine (Linux + macOS)
|
|
```
|
|
|
|
`<name>` must be a **single label** (`book`, not `book.devbox`). It serves over
|
|
**http://** (no TLS on a private box).
|
|
|
|
Curated apps live in `/etc/once-add/apps.toml` (seeded with Writebook + Campfire
|
|
on first run) — edit it to add your own picks for the wizard.
|
|
|
|
### Why the TUI / plain `once deploy` DON'T work here (verified, important)
|
|
Two hard constraints, both confirmed on this box:
|
|
1. **`once` is statically linked** (`file /usr/local/bin/once` → "statically
|
|
linked, Go") → pure-Go resolver → it queries `/etc/hosts` + real DNS only,
|
|
**never mDNS/nss**. So its post-deploy `GET http://<host>/up` verification
|
|
can't resolve a `.local` (or `.localhost`) name, verification fails, and
|
|
`VerifyHTTPOrRemove` **deletes the container**.
|
|
2. **The TUI install forces TLS on** (no toggle) and verifies over **https**;
|
|
kamal-proxy can't get a Let's Encrypt cert for a private `.local` host, so it
|
|
fails the same way.
|
|
|
|
`once-add` works around both, which is the whole recipe — replicate by hand if needed:
|
|
```bash
|
|
echo "127.0.0.1 <name>.local" | sudo tee -a /etc/hosts # so verify resolves to loopback
|
|
once deploy <image> --host <name>.local --disable-tls # CLI: TLS off, http verify passes
|
|
```
|
|
`/etc/hosts` is read by the Go resolver before DNS (and Tailscale only rewrites
|
|
`resolv.conf`, not `hosts`, so it survives). `once update <host> --host … --disable-tls`
|
|
changes a live app's host/TLS **without** re-verifying.
|
|
|
|
**Removing a LAN app:** use `once-add remove <name>` rather than the Once UI —
|
|
the UI removes the container but orphans the `/etc/hosts` line, leaving stale
|
|
state. `once-add remove` clears both (and the orphaned-line case where the app
|
|
is already gone).
|
|
|
|
### The LAN-publish side (already automatic)
|
|
`once-mdns-sync.service` reads the `host` field of each running app container's
|
|
`once` label, keeps the `*.local` ones, and runs `avahi-publish -a -R <host>
|
|
<box-ip>` per name (reconciles ~20s; re-registers on IP change). That's what
|
|
makes `<name>.local` resolve on other machines.
|
|
```bash
|
|
systemctl status once-mdns-sync
|
|
journalctl -u once-mdns-sync -f
|
|
pgrep -af avahi-publish # what's currently announced
|
|
docker inspect --format '{{ index .Config.Labels "once" }}' <container> # an app's host
|
|
```
|
|
|
|
Notes:
|
|
- **Single-label only**: Linux `mdns_minimal` resolves `book.local` but NOT
|
|
`book.devbox.local` (multi-label). macOS resolves both; stick to single-label
|
|
for cross-platform.
|
|
- `*.localhost` only resolves on this box; never use it for LAN apps.
|
|
- Clients must speak mDNS (macOS, Linux+nss-mdns, Windows 10+, iOS — most do).
|
|
|
|
## Sending files / messages to or from this box (Omarchy-Send)
|
|
|
|
If `omarchy-send` is installed (offered at first login), it's a
|
|
LocalSend-compatible terminal client living at `~/.local/bin/omarchy-send`.
|
|
Firewall already opens **53317/tcp+udp** for it. LocalSend on phones and the
|
|
desktop discover this box on the LAN automatically.
|
|
|
|
```bash
|
|
omarchy-send # the TUI (Devices / Transfers / Messages / Manage / Settings)
|
|
omarchy-send --alias "Gold Cluster" # one-off alias override
|
|
```
|
|
|
|
The TUI needs a real TTY — over SSH that means an interactive session (or
|
|
`ssh -tt user@host omarchy-send` for scripted runs).
|
|
|
|
### Headless one-shot message send (no TTY — for scripts/cron)
|
|
|
|
Since **v0.1.6** a plain-text message can be sent with NO TUI and NO TTY, so it
|
|
works from a bare `ssh user@host '...'`, a cron job, or a deploy hook on this
|
|
headless box — ideal for pinging your desktop or phone from the server:
|
|
|
|
```bash
|
|
omarchy-send --to "<peer alias>" --message "backup finished"
|
|
omarchy-send --to "Slate Starburst" --message "deploy done" --wait 20s # peer-discovery timeout (default 15s)
|
|
omarchy-send --to "<alias>" --message "hi" --send-pin 2468 # if the target requires a PIN
|
|
```
|
|
|
|
Matches the target by display name (case-insensitive), discovers it over
|
|
multicast (waits up to `--wait`), sends, prints a one-line result, and exits
|
|
non-zero if the peer isn't found or the send fails. Discovery-only — it doesn't
|
|
bind the receiver port, so it's safe to run while the TUI is also up. File
|
|
sending stays in the TUI for now.
|
|
|
|
### Desktop notifications
|
|
|
|
A running receiver raises a `notify-send` notification on an incoming
|
|
message/file. On this headless box it self-disables (no `notify-send` / session
|
|
bus), so it's a no-op here — its value is that a *desktop* running omarchy-send
|
|
pops a mako toast when this server messages it. Suppress with `--no-notify` or
|
|
the `n` key in Settings.
|
|
|
|
If install was skipped:
|
|
|
|
```bash
|
|
curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash
|
|
```
|
|
|
|
Received files land in `~/Omarchy-Send/`.
|
|
|
|
## journald cheatsheet
|
|
```bash
|
|
journalctl -u sshd --since "1 hour ago"
|
|
journalctl -p err --since today
|
|
journalctl -f # tail
|
|
journalctl -b -p warning # warnings since last boot
|
|
```
|
|
|
|
# Defaults to prefer when the user asks to deploy something
|
|
|
|
1. **Once** for self-hostable web apps (it's installed here) — host-routed via its proxy. For a LAN-reachable app, use **`once-add`** (interactive) or **`once-add <name> <image>`** (→ `http://<name>.local`); do NOT use the Once TUI or plain `once deploy` for `.local` apps — they fail here (see the "Deploying a Once app reachable on the LAN" recipe above for why). To remove, use `once-add remove <name>` (the Once UI orphans the `/etc/hosts` line).
|
|
2. **System package** (`pacman -S`) if it's in core/extra.
|
|
3. **Docker compose** for other app-shaped things; `/srv/<app>/compose.yml` is a sensible location.
|
|
4. **AUR via yay** (installed by omaterm) for things only on AUR.
|
|
5. Avoid `curl | bash` upstream installers unless well-known.
|
|
|
|
# Debug-first-look heuristics
|
|
|
|
- Service won't start: `systemctl status <unit>` then `journalctl -u <unit> -n 50`.
|
|
- Unreachable on the network: `ip a` (link up?), `nmcli con show --active`, then `resolvectl status`.
|
|
- Port "closed" but service running: check `sudo ufw status` first — most likely the firewall.
|
|
- Slow SSH connect: `sudo journalctl -u sshd -f` while reconnecting from another shell.
|
|
|
|
# Provenance
|
|
|
|
The build script that produced this image lives at `~/Projects/omalocal/omalocal.sh` on the master machine (not on this host). To rebuild and reinstall: run that script there, `dd` the output ISO to a USB, boot from it. The build is reproducible — same inputs (stock Arch ISO + master's SSH key) give the same output.
|
|
SKILL_EOF
|
|
|
|
cat > "/mnt/home/$USERNAME/.claude/skills/omalocal-server/AGENTS.md" <<'AGENTS_EOF'
|
|
# Project Overview
|
|
|
|
Once is a CLI/TUI tool for installing and managing web applications from Docker images. It's designed to make self-hosting as easy as possible.
|
|
|
|
Once uses a proxy server (github.com/basecamp/kamal-proxy) to route traffic to the application containers, which allows it to provide zero-downtime restarts and upgrades, automatic SSL, and multiple applications running on a single server. A single instance of Once may deploy one proxy container, along with multiple application containers. Host-based routing is used inside the proxy to route traffic to the correct application container.
|
|
|
|
# Code Architecture
|
|
|
|
## Design and Concepts
|
|
|
|
### Container Naming
|
|
|
|
All containers are namespaced (default namespace: "once"):
|
|
|
|
- Proxy: `{namespace}-proxy`
|
|
- Apps: `{namespace}-app-{appName}-{shortID}`
|
|
|
|
This allows us to easily identify the app containers, but still allows us to boot a second app container without naming collisions when we want to deploy a new version without downtime.
|
|
|
|
### Data and State
|
|
|
|
Once deals primarily with two classes of state:
|
|
|
|
**Application data**: this is stored in Docker volumes to provide persistent storage between versions of the app container. Once provides one volume for each app, which it will mount into two locations (`/storage` and `/rails/storage`) to match typical app conventions. Once may provide backup and restore features on the contents of those volumes. But it does not otherwise touch the volume contents, or have any opinions about what should be in there -- this data is entirely for each app's own use.
|
|
|
|
**Configuration**: this keeps track of the applications and settings that have been set up using Once itself. For example, the list of applications that are deployed, the hostname and TLS settings for each, any custom port settings for the proxy, and so on. Once stores this information as JSON strings in an `once` label on the containers and volumes, so that everything is stored within the Docker state. Application settings go with the app container; proxy settings go with the proxy container; volume settings (like encryption keys) go with the volume.
|
|
|
|
# Build Commands
|
|
|
|
```bash
|
|
make build # Build binary to bin/ (CGO disabled)
|
|
make test # Run unit tests (internal/ packages)
|
|
make integration # Run integration tests (requires Docker)
|
|
make lint # Run golangci-lint
|
|
```
|
|
|
|
Run a single test:
|
|
|
|
```bash
|
|
go test -v -run TestName ./internal/...
|
|
```
|
|
|
|
# Coding Style
|
|
|
|
- Always follow idiomatic Go style
|
|
- Don't use excessive comments. Try to make the code speak for itself; only resort to adding comments where the meaning or intention may otherwise be unclear, or a subtle detail may be missed.
|
|
- Organize imports in sections: stdlib imports, 3rd-party imports, and project imports. Each section should be sorted alphabetically, and the sections should be separated from each other by 1 blank line.
|
|
- Where a struct type has both public and private methods, arrange the public methods first, then add a comment line of `// Private` and put the private methods below that comment. In the public section, put any constructor methods first, followed by simple accessor methods, followed by the rest.
|
|
- Private functions that are not methods of a type should go last in the file, and should be separated with a `// Helpers` comment.
|
|
- Prefer modern Go constructs where possible (for example, ranging over numbers is better than a for loop). If the LSP suggests there is an option to modernize something you should generally do so.
|
|
- Write tests to cover changes where possible, but don't go overboard trying to cover every single case. Prefer using helper functions (including functions defined locally inside the test) over table-driven tests, except in cases where the latter would be more readable.
|
|
- When writing tests, use github.com/stretchr/testify's assert and require packages, to make test conditions more readable. Don't add descriptions to the assertions unless they would add meaningful context over what the default failure message would be).
|
|
- Regularly check your work with the linter and LSP to ensure it follows conventions, and run tests as needed to ensure they pass.
|
|
- Consider opportunities to refactor large methods into smaller pieces, and spot opportunities where it's worth extracting functionality into a new type. But do not go overboard with this.
|
|
|
|
# Agent behaviour
|
|
|
|
Don't make commits, or push changes to remotes. I will take care of this myself.
|
|
AGENTS_EOF
|
|
|
|
# CLAUDE.md is loaded into EVERY Claude Code session on this host regardless of
|
|
# working directory, unlike the skill (invoked on demand). Put the few rules an
|
|
# agent must always respect here — most importantly the Once deploy-domain
|
|
# convention, so an agent that deploys an app picks a LAN-reachable hostname.
|
|
echo "==> Writing host conventions to $USERNAME's ~/.claude/CLAUDE.md..."
|
|
cat > "/mnt/home/$USERNAME/.claude/CLAUDE.md" <<'CLAUDE_MD'
|
|
# This host (Arch server provisioned by omalocal)
|
|
|
|
Headless Arch Linux box on a LAN. Rules to respect in every session:
|
|
|
|
## Deploying a web app with Once (reachable on the LAN)
|
|
|
|
Use `once-add` — Bubble Tea wizard or one-liner:
|
|
|
|
```bash
|
|
once-add # interactive wizard (Add / Remove)
|
|
once-add book ghcr.io/basecamp/writebook # non-interactive add
|
|
once-add remove book # remove app + its /etc/hosts entry
|
|
# -> app reachable at http://<name>.local from any machine on the LAN
|
|
```
|
|
|
|
Use a **single-label** name (`book`, `blog`) → the app is served as
|
|
`<name>.local`. Reach it over **http://** (no TLS on the LAN).
|
|
|
|
Do NOT use the Once **TUI** to deploy a LAN app, and do NOT hand-run plain
|
|
`once deploy <image>`. Both fail on this box, for two verified reasons:
|
|
- The `once` binary is statically linked (pure-Go resolver) — its post-deploy
|
|
verification can't resolve mDNS/`.local`, only `/etc/hosts` or real DNS.
|
|
- The TUI install forces TLS on and can't get a Let's Encrypt cert for a
|
|
private `.local` host, so it rolls the app back.
|
|
|
|
`once-add` works around both: it adds `127.0.0.1 <name>.local` to `/etc/hosts`
|
|
(so verify resolves to loopback) and runs `once deploy --host <name>.local
|
|
--disable-tls`. The `once-mdns-sync` daemon then publishes `<name>.local` to the
|
|
LAN. If you must do it by hand, replicate exactly those steps.
|
|
|
|
To **remove** a LAN app use `once-add remove <name>` rather than the Once UI —
|
|
the UI removes the container but orphans the `/etc/hosts` line. `once-add
|
|
remove` clears both (and works for the orphaned-line case where the app's
|
|
already gone).
|
|
|
|
Avoid: multi-label names like `book.devbox.local` (Linux clients' `mdns_minimal`
|
|
only resolves single-label `.local`), `*.localhost` (only this box can reach it),
|
|
and TLS/https (no cert on a private box).
|
|
|
|
## Files sent to this box (Omarchy-Send)
|
|
|
|
If `omarchy-send` is installed (offered at first login), it's a
|
|
LocalSend-compatible TUI for file transfer + plain-text messaging over the LAN.
|
|
Firewall already opens **53317/tcp+udp**. Anything anyone sends to this box
|
|
lands in **`~/Omarchy-Send/`** — that's the first place to look for files a
|
|
user says they "just sent over". Sub-folder structure is preserved.
|
|
|
|
```bash
|
|
ls -la ~/Omarchy-Send/ # what's been received
|
|
omarchy-send # open the TUI (Devices / Transfers / Messages / Manage / Settings)
|
|
```
|
|
|
|
The TUI needs a real TTY — over SSH that means an interactive session (or
|
|
`ssh -tt user@host omarchy-send`). To send a one-off message with NO TTY (from
|
|
a script/cron on this headless box — e.g. ping a desktop/phone), use the
|
|
headless mode added in v0.1.6:
|
|
|
|
```bash
|
|
omarchy-send --to "<peer alias>" --message "backup finished" # also --send-pin, --wait; file send stays TUI-only
|
|
```
|
|
|
|
If install was skipped, run:
|
|
|
|
```bash
|
|
curl -fsSL https://raw.githubusercontent.com/28allday/omarchy-send/main/install.sh | bash
|
|
```
|
|
|
|
## Baseline
|
|
|
|
- SSH is **key-only** (no passwords). Add keys to `~/.ssh/authorized_keys`.
|
|
- Firewall is **ufw**, default-deny inbound; open ports with `sudo ufw allow`.
|
|
Already open: 22, 80, 443, 5353/udp (mDNS), 53317 (LocalSend).
|
|
- Full host playbook, recipes (Once / once-add / omarchy-send / mDNS / journald)
|
|
are in the `omalocal-server` skill at `~/.claude/skills/omalocal-server/`.
|
|
CLAUDE_MD
|
|
|
|
arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "/home/$USERNAME/.claude"
|
|
|
|
echo "==> Writing pre-login console banner (hostname + IPv4 + ssh hint)..."
|
|
# agetty expands these escapes when it prints /etc/issue:
|
|
# \n = hostname, \4 = primary IPv4, \r = kernel, \l = tty
|
|
cat > /mnt/etc/issue <<ISSUE
|
|
|
|
==================================================
|
|
|
|
Arch Linux \r
|
|
|
|
Hostname: \n
|
|
IPv4: \4
|
|
|
|
ssh $USERNAME@\4
|
|
|
|
==================================================
|
|
|
|
ISSUE
|
|
|
|
echo "==> Writing boot entry..."
|
|
ROOT_PARTUUID=$(blkid -s PARTUUID -o value "$ROOT_PART")
|
|
cat > /mnt/boot/loader/entries/arch.conf <<ENTRY
|
|
title Arch Linux
|
|
linux /vmlinuz-linux
|
|
initrd /intel-ucode.img
|
|
initrd /amd-ucode.img
|
|
initrd /initramfs-linux.img
|
|
options root=PARTUUID=$ROOT_PARTUUID rw
|
|
ENTRY
|
|
|
|
# -------------------------------------------------------- wifi handoff -------
|
|
if [ -n "$WIFI_SSID" ]; then
|
|
echo "==> Saving Wi-Fi profile for first boot..."
|
|
mkdir -p /mnt/etc/NetworkManager/system-connections
|
|
WIFI_UUID=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)
|
|
WIFI_FILE_NAME=$(echo "$WIFI_SSID" | tr '/ ' '__')
|
|
NMFILE="/mnt/etc/NetworkManager/system-connections/${WIFI_FILE_NAME}.nmconnection"
|
|
{
|
|
echo "[connection]"
|
|
echo "id=$WIFI_SSID"
|
|
echo "uuid=$WIFI_UUID"
|
|
echo "type=wifi"
|
|
echo "autoconnect=true"
|
|
echo
|
|
echo "[wifi]"
|
|
echo "mode=infrastructure"
|
|
echo "ssid=$WIFI_SSID"
|
|
if [ -n "$WIFI_PSK" ]; then
|
|
echo
|
|
echo "[wifi-security]"
|
|
echo "key-mgmt=wpa-psk"
|
|
echo "psk=$WIFI_PSK"
|
|
fi
|
|
echo
|
|
echo "[ipv4]"
|
|
echo "method=auto"
|
|
echo
|
|
echo "[ipv6]"
|
|
echo "method=auto"
|
|
} > "$NMFILE"
|
|
chmod 600 "$NMFILE"
|
|
fi
|
|
|
|
echo
|
|
echo "=================================================================="
|
|
echo " Install complete. Remove the install media and reboot."
|
|
echo
|
|
echo " Then from your master machine:"
|
|
echo " ssh $USERNAME@<this-host-ip> (or ssh $USERNAME@$HOSTNAME)"
|
|
echo
|
|
echo " On your first login you'll be offered OMATERM + Once. To set"
|
|
echo " them up manually at any time:"
|
|
echo " curl -fsSL https://omaterm.org/install | bash"
|
|
echo " curl https://get.once.com | ONCE_INTERACTIVE=false sh"
|
|
echo "=================================================================="
|
|
echo
|
|
read -rp " Press ENTER to reboot now (Ctrl+C for a shell)... " </dev/tty
|
|
umount -R /mnt || true
|
|
reboot
|
|
__INSTALLER_PAYLOAD__
|
|
chmod +x "$1"
|
|
}
|
|
|
|
# ===========================================================================
|
|
# Host-side workflow: locate stock ISO, unsquash, inject,
|
|
# re-squash, repack, output.
|
|
# ===========================================================================
|
|
|
|
# ---- locate stock ISO ----------------------------------------------------
|
|
shopt -s nullglob
|
|
CANDIDATES=("$SCRIPT_DIR"/archlinux-*.iso)
|
|
shopt -u nullglob
|
|
STOCK_ISO=""
|
|
for iso in "${CANDIDATES[@]}"; do
|
|
[[ "$(basename "$iso")" == *omalocal* ]] && continue
|
|
STOCK_ISO="$iso"
|
|
break
|
|
done
|
|
|
|
if [ -z "$STOCK_ISO" ]; then
|
|
echo "ERROR: No stock Arch ISO found in $SCRIPT_DIR" >&2
|
|
echo >&2
|
|
echo "Download the latest from https://archlinux.org/download/ and put it" >&2
|
|
echo "in this folder (filename must start with 'archlinux-')." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo " Stock ISO: $(basename "$STOCK_ISO")"
|
|
echo " Output: $(basename "$OUT_ISO")"
|
|
echo
|
|
|
|
# ---- tooling check -------------------------------------------------------
|
|
for cmd in xorriso unsquashfs mksquashfs git sha512sum; do
|
|
command -v "$cmd" >/dev/null || {
|
|
echo "ERROR: missing tool: $cmd" >&2
|
|
echo " install with: sudo pacman -S libisoburn squashfs-tools git coreutils" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
# ---- locate the master SSH public key -----------------------------------
|
|
# Override default with: SSH_PUBKEY=/path/to/key.pub ./omalocal.sh
|
|
SSH_PUBKEY="${SSH_PUBKEY:-$HOME/.ssh/id_ed25519.pub}"
|
|
if [ ! -f "$SSH_PUBKEY" ]; then
|
|
# Fall back to id_rsa.pub if ed25519 isn't there
|
|
if [ -f "$HOME/.ssh/id_rsa.pub" ]; then
|
|
SSH_PUBKEY="$HOME/.ssh/id_rsa.pub"
|
|
else
|
|
echo "ERROR: no SSH public key found." >&2
|
|
echo " The output ISO is key-only — without a key it'd be unreachable." >&2
|
|
echo " Generate one with: ssh-keygen -t ed25519" >&2
|
|
echo " or pass SSH_PUBKEY=/path/to/key.pub ./omalocal.sh" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
echo " SSH key: $SSH_PUBKEY"
|
|
|
|
# ---- workspace -----------------------------------------------------------
|
|
sudo rm -rf "$WORK"
|
|
mkdir -p "$WORK"
|
|
SFS_DIR="$WORK/airootfs"
|
|
|
|
# ---- pull squashfs out of the stock ISO ---------------------------------
|
|
echo "==> Extracting airootfs.sfs from stock ISO..."
|
|
xorriso -osirrox on -indev "$STOCK_ISO" \
|
|
-extract /arch/x86_64/airootfs.sfs "$WORK/airootfs-stock.sfs" 2>&1 | tail -3
|
|
|
|
echo "==> Unsquashing airootfs (slow: ~1 min)..."
|
|
sudo unsquashfs -d "$SFS_DIR" "$WORK/airootfs-stock.sfs" >/dev/null
|
|
|
|
# ---- inject payload ------------------------------------------------------
|
|
echo "==> Staging master SSH key for first-boot SSH access..."
|
|
sudo install -m 644 -o root -g root "$SSH_PUBKEY" "$SFS_DIR/root/master.pub"
|
|
|
|
echo "==> Injecting installer.sh..."
|
|
INSTALLER_TMP="$WORK/installer.sh"
|
|
write_installer "$INSTALLER_TMP"
|
|
sudo install -m 755 -o root -g root "$INSTALLER_TMP" "$SFS_DIR/root/installer.sh"
|
|
|
|
echo "==> Wiring getty@tty1 to auto-launch the installer..."
|
|
sudo mkdir -p "$SFS_DIR/etc/systemd/system/getty@tty1.service.d"
|
|
sudo tee "$SFS_DIR/etc/systemd/system/getty@tty1.service.d/override.conf" >/dev/null <<'EOF'
|
|
[Service]
|
|
ExecStart=
|
|
ExecStart=-/usr/bin/bash /root/installer.sh
|
|
StandardInput=tty
|
|
StandardOutput=tty
|
|
Restart=no
|
|
RestartPreventExitStatus=1 2 3 4 5 6 7 8
|
|
EOF
|
|
|
|
# ---- extend USB enumeration timeout in boot loader cmdline --------------
|
|
# Slow / fussy USB controllers (some Intel mini-PCs, NUCs, ZimaBoard) don't
|
|
# enumerate USB block devices within archiso's default search window. Adding
|
|
# rootdelay=60 to the kernel cmdline makes initramfs wait 60s before mounting
|
|
# root — fast hardware finds the device in 2s and moves on; slow hardware
|
|
# gets the breathing room it needs.
|
|
echo "==> Patching bootloader cmdlines (rootdelay=60 for slow USB enumeration)..."
|
|
mkdir -p "$WORK/boot-edit"
|
|
BOOT_CFG_FILES=(
|
|
/loader/entries/01-archiso-linux.conf
|
|
/loader/entries/02-archiso-speech-linux.conf
|
|
/boot/syslinux/archiso_sys-linux.cfg
|
|
)
|
|
for path in "${BOOT_CFG_FILES[@]}"; do
|
|
dest="$WORK/boot-edit/$(basename "$path")"
|
|
# Don't pipe xorriso straight into tail — that masks its exit status under
|
|
# 'set -e', so a missing boot file would slip through here and instead blow
|
|
# up later at the unconditional -map during repack, with a murkier error.
|
|
if ! xorriso -osirrox on -indev "$STOCK_ISO" -extract "$path" "$dest" 2>"$WORK/xorriso-extract.log"; then
|
|
echo "ERROR: failed to extract $path from stock ISO:" >&2
|
|
tail -3 "$WORK/xorriso-extract.log" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
# Only touch lines that already carry archiso boot args. Idempotent — only
|
|
# appends the param if it isn't already present.
|
|
# rootdelay=60 — give slow USB controllers time to enumerate
|
|
# consoleblank=0 — never DPMS-blank the live install console
|
|
for f in "$WORK/boot-edit"/*; do
|
|
[ -f "$f" ] || continue
|
|
sed -i -E '/archisosearchuuid=/ { /rootdelay=/! s/$/ rootdelay=60/ }' "$f"
|
|
sed -i -E '/archisosearchuuid=/ { /consoleblank=/! s/$/ consoleblank=0/ }' "$f"
|
|
done
|
|
|
|
# ---- re-squash -----------------------------------------------------------
|
|
echo "==> Re-squashing airootfs (slow: ~2 min)..."
|
|
sudo rm -f "$WORK/airootfs.sfs"
|
|
sudo mksquashfs "$SFS_DIR" "$WORK/airootfs.sfs" \
|
|
-comp xz -Xbcj x86 -b 1M -noappend -no-progress -quiet
|
|
|
|
# ---- regenerate sha512 --------------------------------------------------
|
|
echo "==> Recalculating airootfs.sha512..."
|
|
( cd "$WORK" && sudo sha512sum airootfs.sfs | sudo tee airootfs.sha512 >/dev/null )
|
|
|
|
# ---- repack ISO ---------------------------------------------------------
|
|
echo "==> Repacking ISO (preserving stock boot config + hybrid MBR + volume UUID)..."
|
|
sudo rm -f "$OUT_ISO"
|
|
|
|
# Force output volume UUID to match the stock's. archiso's initramfs hook
|
|
# searches for the boot media by this UUID (it's also baked into the kernel
|
|
# cmdline as archisosearchuuid=) — if xorriso regenerates the UUID with a
|
|
# fresh build timestamp, the search misses the USB entirely and falls back
|
|
# to scanning only partition-typed devices, which the hybrid layout doesn't
|
|
# fully expose. Pinning the UUID keeps stock cmdline + marker file in sync.
|
|
STOCK_UUID=$(blkid -s UUID -o value "$STOCK_ISO")
|
|
STOCK_UUID_RAW=$(echo "$STOCK_UUID" | tr -d '-')
|
|
|
|
xorriso \
|
|
-indev "$STOCK_ISO" \
|
|
-outdev "$OUT_ISO" \
|
|
-volume_date "uuid" "$STOCK_UUID_RAW" \
|
|
-boot_image any keep \
|
|
-boot_image any "system_area=$STOCK_ISO" \
|
|
-rm /arch/x86_64/airootfs.sfs -- \
|
|
-map "$WORK/airootfs.sfs" /arch/x86_64/airootfs.sfs \
|
|
-rm /arch/x86_64/airootfs.sha512 -- \
|
|
-map "$WORK/airootfs.sha512" /arch/x86_64/airootfs.sha512 \
|
|
-rm /loader/entries/01-archiso-linux.conf -- \
|
|
-map "$WORK/boot-edit/01-archiso-linux.conf" /loader/entries/01-archiso-linux.conf \
|
|
-rm /loader/entries/02-archiso-speech-linux.conf -- \
|
|
-map "$WORK/boot-edit/02-archiso-speech-linux.conf" /loader/entries/02-archiso-speech-linux.conf \
|
|
-rm /boot/syslinux/archiso_sys-linux.cfg -- \
|
|
-map "$WORK/boot-edit/archiso_sys-linux.cfg" /boot/syslinux/archiso_sys-linux.cfg \
|
|
-end 2>&1 | tail -5
|
|
|
|
# ---- cleanup ------------------------------------------------------------
|
|
sudo chown "$INVOKING_USER:$INVOKING_GROUP" "$OUT_ISO"
|
|
sudo rm -rf "$WORK"
|
|
|
|
echo
|
|
echo "=========================================================="
|
|
echo " Done: $OUT_ISO"
|
|
echo " Size: $(du -h "$OUT_ISO" | cut -f1)"
|
|
echo
|
|
echo " Burn to USB:"
|
|
echo " sudo dd if='$OUT_ISO' of=/dev/sdX bs=4M status=progress conv=fsync"
|
|
echo
|
|
echo " Or drop $(basename "$OUT_ISO") into your Ventoy USB."
|
|
echo "=========================================================="
|