diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b197d03..075eabd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -28,7 +28,10 @@ "Bash(git push *)", "Bash(gh run *)", "Bash(python3 -c ' *)", - "Bash(bash -n test/vm.sh)" + "Bash(bash -n test/vm.sh)", + "Bash(git worktree *)", + "Bash(git --no-pager diff --stat)", + "Bash(python3 -c \"import yaml,sys; yaml.safe_load\\(open\\('config.example.yaml'\\)\\); print\\('config.example.yaml: YAML OK'\\)\")" ] } } diff --git a/.github/workflows/e2e-disks.yml b/.github/workflows/e2e-disks.yml index 9d22314..0cdf323 100644 --- a/.github/workflows/e2e-disks.yml +++ b/.github/workflows/e2e-disks.yml @@ -9,9 +9,9 @@ # A full install-to-completion is deliberately NOT run here: a bare container has # no booted systemd, so archinstall's install phase fails on udev/D-Bus # (timedatectl, systemctl) assumptions it makes about a live ISO. Real end-to-end -# installs run in the QEMU VM harness instead (test/vm.sh / `make vm`), which +# installs run in the QEMU VM harness instead (test/vm.sh / `task vm`), which # boots a real systemd. `test/e2e/disks.sh --mode full` still works for manual -# runs on a real host/VM (`make e2e-disks-full`); it's just not container CI. +# runs on a real host/VM (`task e2e-disks-full`); it's just not container CI. name: e2e-disks on: diff --git a/Makefile b/Makefile deleted file mode 100644 index 6745391..0000000 --- a/Makefile +++ /dev/null @@ -1,40 +0,0 @@ -# Convenience targets. The Go workflow stays `go build/test/vet`; these wrap the -# Tier 2 loopback integration harness, which needs root (losetup/lvm/archinstall). - -.PHONY: build test vet e2e-disks-light e2e-disks-full vm vm-fresh vm-disk - -build: - go build -o archwright . - -test: - go test ./... - -vet: - go vet ./... - -# Light: validate archwright's rendered archinstall JSON against a real -# archinstall (--dry-run) on loop devices. No install, no network. -# make e2e-disks-light # default multi-disk + xfs -# make e2e-disks-light LAYOUT=single-disk-lvm FS=ext4 -LAYOUT ?= multi-disk-lvm -FS ?= xfs -e2e-disks-light: - sudo bash test/e2e/disks.sh --mode light --layout $(LAYOUT) --fs $(FS) - -# Full: real partition/format/pacstrap onto loop devices, then assert layout. -# Slow; needs network + disk space. -e2e-disks-full: - sudo bash test/e2e/disks.sh --mode full --layout $(LAYOUT) --fs $(FS) \ - --disk1-size 12G --extra-size 6G - -# Interactive QEMU smoke test of the full flow. Builds + boots a UEFI VM with -# the repo shared in over 9p (binary + config show up inside the live ISO). -# make vm # boot the Arch live ISO (run Phase A) -# make vm-fresh # same, but wipe the virtual disks first -# make vm-disk # boot the installed system off disk 1 -vm: - bash test/vm.sh iso -vm-fresh: - bash test/vm.sh iso --fresh -vm-disk: - bash test/vm.sh disk diff --git a/README.md b/README.md index a9ad660..2b515ac 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,70 @@ > for production use** — only for personal use and messing around. There are almost certainly > much better tools out there to handle this. Use at your own risk. -A declarative way to rebuild an Arch Linux machine from bare disks to a -themed KDE desktop. One config file (`config.yaml`) drives a single static Go binary -(`archwright`). - -Phase A renders your `config.yaml` into an [archinstall](https://github.com/archlinux/archinstall) -configuration and lets the official installer do the partitioning, LVM, pacstrap and -bootloader install. Phase B then does the post-install customization (packages, flatpaks, -1Password, Plymouth, GRUB/KDE theming, dotfiles) directly. +A declarative way to rebuild an Arch Linux machine from bare disks to a themed KDE desktop. +One config file (`config.yaml`) drives a single static Go binary (`archwright`) — no +`bash`/`yq`/`jq` runtime dependencies, just the binary. It does **not** try to be NixOS — there's no purity, no rollback, no DSL. It's plain YAML -you can read top to bottom plus a binary that orchestrates archinstall and the usual Arch -tools (`yay`, `flatpak`, …), designed for the "I had to reinstall again" workflow. -No `bash`/`yq`/`jq` runtime dependencies — just the binary. +you can read top to bottom plus a binary that orchestrates +[archinstall](https://github.com/archlinux/archinstall) and the usual Arch tools (`yay`, +`flatpak`, `chezmoi`, …), designed for the "I had to reinstall again" workflow. + +## The two-phase model + +Everything happens in two phases, each a sequence of numbered, individually re-runnable, +dry-run-aware stages: + +- **`install` (Phase A)** — on the live ISO, as **root**. Renders your `config.yaml` into an + archinstall config and lets the official installer do partitioning, LVM/btrfs, pacstrap and + the bootloader. Then, in the post-install chroot, it sets up custom repos and kernels (so the + *first* boot already uses them) and stages the binary + flattened config for Phase B. +- **`bootstrap` (Phase B)** — on the booted system, as **your user**. Post-install + customization: the AUR helper, packages, flatpaks, snapshots, boot splash, GRUB/KDE theming, + dotfiles, and a final user-defined `setup` step. + +> **⚠️ archinstall version coupling.** archinstall's JSON config is *not* a stable API; its +> schema changes between releases. Archwright renders against the version pinned in +> [`internal/archinstall`](internal/archinstall/archinstall.go) (`Version`), and `preflight` +> only *warns* if the live ISO ships a different one. **Validate the generated config against a +> real archinstall run in a VM** (see [Testing in a VM](#testing-in-a-vm)) before trusting it on +> hardware. After any archinstall version bump, diff the schema and update that package + +> `Version` together. + +## Contents + +- [Quick start](#quick-start) +- [Installing the binary](#installing-the-binary) +- [Commands & flags](#commands--flags) +- [Configuration reference](#configuration-reference) +- [Remote & layered configuration](#remote--layered-configuration) +- [Stages reference](#stages-reference) +- [Validation](#validation) +- [Testing in a VM](#testing-in-a-vm) +- [For contributors](#for-contributors) + +## Quick start -> **archinstall version coupling.** archinstall's JSON config is *not* a stable API; its -> schema changes between releases. We render against the version in -> [`internal/archinstall`](internal/archinstall/archinstall.go) (`Version`), and preflight -> warns if the live ISO ships a different one. Validate the generated config against a real -> archinstall run in a VM (see [Testing in a VM](#testing-in-a-vm-recommended-before-real-hardware)) -> before trusting it on hardware. +```sh +# 1. Configure +cp config.example.yaml config.yaml +$EDITOR config.yaml # set disks, hostname, user, packages, themes +./archwright validate # sanity-check the config first -## Build / install +# 2. Phase A — from the Arch live ISO (UEFI), online, as root +./archwright install --dry-run # review the exact plan, run nothing +./archwright install # type ERASE when prompted, then reboot + +# 3. Phase B — after reboot, as your user (the binary was staged in ~/) +./archwright bootstrap --dry-run +./archwright bootstrap +``` + +`config.yaml` is **gitignored**, so your real machine config stays out of the repo. +**Double-check `disks:` — Phase A erases those devices.** Always run `--dry-run` first: every +destructive command is printed (and recorded into a plan) without executing. + +## Installing the binary ```sh go build -o archwright . # local build @@ -35,76 +77,453 @@ go install ./... # into $GOBIN go build -ldflags "-s -w -X main.version=$(git describe --tags --always)" -o archwright . ``` -Or grab a prebuilt binary from a GitHub release (see [Releases](#releases)). +Or grab a prebuilt binary from a [GitHub release](#releases). -## Commands +## Commands & flags ``` -archwright install [--dry-run] [--only ] [--config ]... [--yes] -archwright bootstrap [--dry-run] [--only ] [--config ]... -archwright validate [--config ]... -archwright render [--config ]... -o +archwright install [flags] [--yes] +archwright bootstrap [flags] +archwright validate [flags] +archwright render [flags] [-o ] +archwright list-stages archwright --version ``` -| Command | Phase | Run as | What it does | -|-------------|-------|--------|--------------| -| `install` | A | root, from the Arch live ISO | (optional) pick mirrors with reflector; probe disk geometry; render an archinstall config (disk 1 = ESP+swap+LVM-PV partitions; extra disks = full-disk PVs; one VG→XFS root LV) + credentials; run `archinstall --silent`; then in the target chroot configure custom repos (e.g. CachyOS) + install custom kernels (replace stock, set GRUB default) and stage the binary+**flattened config** for Phase B | -| `bootstrap` | B | your user, after reboot | yay, packages, flatpaks, AUR, Plymouth, GRUB theme, KDE customization, `chezmoi init --apply`, then post-dotfiles `setup` (idempotent git clones + commands) | -| `validate` | — | anyone | resolve + merge + validate the config, change nothing | -| `render` | — | anyone | resolve `--config` refs, merge their `imports:`, write the single flattened config to `-o`, change nothing — see [Remote & layered configuration](#remote--layered-configuration) | +| Command | Phase | Run as | What it does | +|---------------|-------|--------|--------------| +| `install` | A | root, from the Arch live ISO | (optionally) pick mirrors with reflector → probe disk geometry → render an archinstall config + credentials → run `archinstall --silent` → then in the target chroot configure custom repos + kernels and stage the binary + **flattened config** for Phase B | +| `bootstrap` | B | your user, after reboot | AUR helper, packages, flatpaks, snapper, plymouth, GRUB theme, KDE, dotfiles, then the user-defined `setup` steps | +| `validate` | — | anyone | resolve + merge + validate the config; change nothing | +| `render` | — | anyone | resolve `--config` refs, merge their `imports:`, write the single flattened config to `-o`; change nothing — see [Remote & layered configuration](#remote--layered-configuration) | +| `list-stages` | — | anyone | print every registered stage with its order, name and phase (the source of truth for `--only`/`--skip`/`stages.disable`) | -### Flags +### Global flags + +These apply to `install`, `bootstrap`, `validate` and `render`: | Flag | Effect | |------|--------| | `--dry-run` | print every command instead of running it (records a full plan; runs nothing) | -| `--only ` | run one stage by name or number (`--only 10`, `--only grub`) | -| `--config ` | config reference (default `config.yaml`); **repeatable** — later refs override earlier (last wins). A ref is a local path, a `github.com/OWNER/REPO/path.yaml[@ref]` shorthand, or a raw URL (see [Remote & layered configuration](#remote--layered-configuration)) | -| `-o ` | (`render` only) where to write the flattened config | +| `--config ` | config reference (default `config.yaml`); **repeatable** — later refs override earlier (last wins). A local path, a `github.com/OWNER/REPO/path.yaml[@ref]` shorthand, or a raw URL — see [Remote & layered configuration](#remote--layered-configuration) | | `--offline` | resolve remote refs from the local cache only (no network) | -| `--yes` | (`install` only) skip the destructive `ERASE` confirm + set a throwaway password — VMs only | +| `--strict` | refuse unpinned github refs (require `@ref`) | +| `--no-color` | disable coloured output (`NO_COLOR` is also honoured) | -## Workflow +### Selecting stages -```sh -# Configure: -cp config.example.yaml config.yaml -$EDITOR config.yaml # set disks, hostname, user, package lists, themes +Four flags slice the stage list (all match a stage **by name or number** — see `list-stages`): -# Phase A — from the Arch live ISO (UEFI), online, as root: -./archwright validate # sanity-check config first -./archwright install --dry-run # review the exact plan -./archwright install # type ERASE when prompted -reboot +| Flag | Effect | +|------|--------| +| `--only ` | run a single stage (`--only 20`, `--only packages`) | +| `--skip ` | skip a stage; **repeatable** (`--skip plymouth --skip grub-theme`) | +| `--from ` | resume from a stage onwards (inclusive) | +| `--to ` | stop after a stage (inclusive) | -# Phase B — after reboot, as your user (binary was staged in ~/): -./archwright bootstrap --dry-run -./archwright bootstrap +To skip stages *persistently* (without passing flags every run), list them under +[`stages.disable`](#stage-selection) in the config. A `--only` on the command line overrides +both `--skip` and `stages.disable`. + +### `install`-only flag + +| Flag | Effect | +|------|--------| +| `--yes` | skip the destructive `ERASE` confirmation and set a throwaway password — **VMs / automation only** | + +### `render`-only flag + +| Flag | Effect | +|------|--------| +| `-o`, `--output ` | where to write the flattened config (default stdout; `-` is also stdout) | + +## Configuration reference + +`config.yaml` is the whole interface. Every value may reference an environment variable with +`${VAR}` syntax (see [Environment variables](#environment-variables)). The struct in +[`internal/config/config.go`](internal/config/config.go) *is* the schema — the +[`config.example.yaml`](config.example.yaml) is an annotated, copy-ready instance of everything +below. + +### `system` + +Base OS identity, generated in Phase A's chroot. + +```yaml +system: + hostname: arch-box + timezone: Australia/Adelaide # timedatectl list-timezones + locale: en_AU.UTF-8 # default LANG; enabled by the installer + locales: # additional locales to also enable in /etc/locale.gen + - en_US.UTF-8 + keymap: us # console keymap; localectl list-keymaps + ntp: true # NTP time sync (defaults to true when omitted) ``` -`config.yaml` is gitignored. **Double-check `disks:` — Phase A erases those devices.** -Always run `--dry-run` first: every destructive command is printed (and recorded as a -plan) without executing. +`hostname`, `timezone`, `locale` and `keymap` are required. `locales` is additive (the default +`locale` is always enabled). `ntp` is a tri-state: omit it for the default (on). -## Validation +### `user` -Config rules are declared as `validate:` struct tags in -[`internal/config/config.go`](internal/config/config.go) (go-playground/validator) — the -struct *is* the schema. `validate` reports every problem at once with YAML-path messages: +```yaml +user: + name: adam + shell: /usr/bin/zsh # must start with / + groups: [wheel] # supplementary groups; `wheel` grants sudo +``` + +`wheel` is what gives the user `sudo` (configured during install) — needed because Phase B runs +as the user and escalates with `sudo`. + +### Stage selection +Skip stages without emptying their config blocks — the persisted equivalent of `--skip`. Match +by stage **name or number** (see `archwright list-stages`). + +```yaml +stages: + disable: [plymouth, grub-theme] ``` -$ archwright validate --config bad.yaml -disks.esp.device must start with "/dev/" -disks.lvm.filesystem must be one of: xfs ext4 -disks.lvm.pvs must have at least 1 item(s) + +### `disks` + +The destructive part, and the most configurable. `layout` is the discriminator; exactly the +matching sub-block must be present. **`layout` defaults to `lvm`** when omitted, so older configs +keep working. + +| `layout` | Shape | +|----------|-------| +| `lvm` (default) | ESP + one LVM volume group spanning the listed PVs → root mounted at `/` | +| `btrfs` | ESP + a single btrfs root carrying subvolumes (snapshot-friendly) | +| `plain` | ESP + a single ext4/xfs root partition, no LVM | + +The ESP is always created on disk 1 (`esp.device`); on the `lvm` layout that disk is partitioned +ESP + remainder-as-PV, and any other PVs listed are consumed as a single full-disk partition. + +```yaml +disks: + layout: lvm + esp: + device: /dev/sda + size: 4GiB + swap: + type: swapfile # swapfile (default) | zram | partition | none + size: 4GiB # match RAM for hibernation + lvm: + vg: vg0 + lv: root + filesystem: xfs # xfs | ext4 + pvs: + - /dev/sda2 # remainder partition of the ESP device + - /dev/sdb # whole second disk + - /dev/sdc # whole third disk ``` +#### Swap × layout compatibility + +`swap.type` defaults to `swapfile`. Not every type is valid for every layout: + +| `swap.type` | What it is | Uses `size`? | Valid layouts | +|-------------|------------|--------------|---------------| +| `swapfile` (default) | post-install `/swapfile` | yes (required) | any — **the only LVM-compatible on-disk option** | +| `zram` | compressed RAM swap (archinstall's own) | no | any | +| `partition` | a real linux-swap partition | yes (required) | `plain`, `btrfs` only (**not** `lvm`) | +| `none` | no swap | no | any | + +On btrfs prefer `zram` or `partition`: a swapfile needs a dedicated nocow/no-compress subvolume, +so it isn't emitted for that layout. + +#### Multiple LVM volumes + +Instead of a single root LV (`lv` + `filesystem`), carve several volumes in the VG — set +`volumes` **instead of** `lv`/`filesystem`, not both. Exactly one volume omits `size` (it takes +the rest of the VG) and exactly one is mounted at `/`. + +```yaml + lvm: + vg: vg0 + pvs: [/dev/sda2, /dev/sdb] + volumes: + - { name: root, mountpoint: /, filesystem: xfs, size: 64GiB } + - { name: home, mountpoint: /home, filesystem: ext4 } # rest of the VG +``` + +#### btrfs layout + +```yaml +disks: + layout: btrfs + esp: { device: /dev/nvme0n1, size: 4GiB } + swap: { type: zram } + btrfs: + device: /dev/nvme0n1 # the single disk holding the btrfs root + compress: zstd # -> compress=zstd mount option (e.g. zstd or zstd:3) + snapshots: snapper # snapper | none (provisions the `snapper` stage in Phase B) + subvolumes: + - { name: "@", mountpoint: / } + - { name: "@home", mountpoint: /home } + - { name: "@log", mountpoint: /var/log } +``` + +`snapshots: snapper` is what activates the [`snapper` stage](#stages-reference) in Phase B (it is +a no-op for any other layout/setting). + +#### plain layout + +```yaml +disks: + layout: plain + esp: { device: /dev/nvme0n1, size: 4GiB } + swap: { type: partition, size: 8GiB } + plain: + device: /dev/nvme0n1 # single disk: ESP + one root partition, no LVM + filesystem: ext4 # ext4 | xfs +``` + +#### Disk encryption (LUKS) + +Omit for no encryption (the default). The LUKS passphrase is the same install password +archwright already collects — it is **not** stored in the config. + +```yaml +disks: + encryption: + type: lvm_on_luks # encrypt the PV partitions, LVM on top +``` + +| `encryption.type` | Effect | Requires | +|-------------------|--------|----------| +| `lvm_on_luks` | encrypt the PV partitions, LVM on top | `lvm` layout, **≤ 2 PVs** (archinstall limit) | +| `luks` | encrypt the single root partition | `plain` or `btrfs` layout | + +> The `lvm_on_luks` 2-PV limit and exact archinstall behaviour are reverse-engineered and +> **VM-validation-pending** — confirm in a VM before relying on it. + +### `mirrors` + +Optional. Runs `reflector` in the live ISO before archinstall so pacstrap (and the installed +system, which inherits the mirrorlist) use fast, recent mirrors. Omit the section, or set +`reflector: false`, to skip it. + +```yaml +mirrors: + reflector: true + countries: [AU] # --country; omit for worldwide + latest: 20 # --latest N most-recently-synced + fastest: 10 # --fastest N by measured download rate + sort: rate # rate | age | score | delay | country + protocols: [https] # --protocol (https | http | rsync | ftp) +``` + +### Software: what goes where + +This is the most error-prone decision, so it's worth stating plainly. Four lists install +packages at different points: + +| Field | When / how | Use for | +|-------|------------|---------| +| `pacstrap` | **Phase A**, by archinstall, verbatim | the minimum the system needs to *boot and run Phase B* — base-devel/git (to build the AUR helper), the login shell, `sudo`, `networkmanager`, `efibootmgr`, CPU microcode | +| `kernel.base` | **Phase A** pacstrap | the bootable baseline kernel(s) — **official-repo only** (custom repos aren't set up yet) | +| `kernel.packages` | **Phase A** chroot, after repo setup | extra/custom kernels (e.g. `linux-cachyos`) so the first boot can run them | +| `packages` | **Phase B**, `pacman -S --needed` | everything else from the official (and custom) repos — the desktop, tools, etc. | +| `aur` | **Phase B**, via the AUR helper | AUR packages (e.g. `1password`) | +| `flatpaks` | **Phase B** | Flatpak apps | + +`pacstrap` is the **complete** Phase-A set, rendered verbatim — nothing is added in code. +`preflight` only *warns* about recommended-but-absent entries; it never re-adds them. + +```yaml +repos: # custom pacman repos (configured in Phase A's chroot) + - name: cachyos + setup: | # a root shell snippet for repos with a maintained installer + curl -fsSL https://mirror.cachyos.org/cachyos-repo.tar.xz | tar -xJ -C /tmp + cd /tmp/cachyos-repo && ./cachyos-repo.sh --install + # Purely declarative repo (no script): + # - name: chaotic-aur + # key: 3056513887B78AEB # imported + locally signed via pacman-key + # keyserver: keyserver.ubuntu.com + # include: /etc/pacman.d/chaotic-mirrorlist # written into the pacman.conf section + +pacstrap: + - base-devel # build the AUR helper in Phase B + - git + - zsh # the user's login shell (user.shell) + - sudo # Phase B escalates via sudo + - networkmanager # network at first boot + - efibootmgr + - intel-ucode # CPU microcode (or amd-ucode) + +kernel: + base: [linux] # pacstrapped baseline; OFFICIAL-repo kernels only + packages: [linux-cachyos, linux-cachyos-headers] # custom kernels (installed in the chroot) + default: linux-cachyos # GRUB default entry; must be in base ∪ packages + replace_stock: true # remove stock `linux` after install (needs ≥1 packages entry) + +packages: [vim, alacritty, dolphin, plasma-meta, starship, fzf] + +flatpak_remotes: # the COMPLETE set — nothing (not even flathub) is implicit + - { name: flathub, url: https://flathub.org/repo/flathub.flatpakrepo } +flatpaks: # each app is "remote:appid"; remote must be declared above + - flathub:com.spotify.Client + - flathub:org.mozilla.firefox + +aur: [1password, 1password-cli] +aur_helper: yay # yay (default) | paru +``` + +> **Why repos and custom kernels are Phase A:** they run in the post-archinstall chroot, written +> into the target's `pacman.conf` + keyring, so the *very first* boot already uses them (boots +> `linux-cachyos`, with stock `linux` removed before it ever boots) and Phase B installs resolve +> against the custom repos too. archinstall must always pacstrap a stock `linux` for a bootable +> baseline; `kernel.replace_stock` removes it in the chroot before reboot. + +### `bootloader` + +```yaml +bootloader: + kind: grub # grub (default) | systemd-boot +``` + +`grub` is the default. `systemd-boot` is reverse-engineered and **VM-validation-pending** (no +`grub.cfg`; cmdline edits go to `/etc/kernel/cmdline`, and `bootctl update` replaces +`grub-mkconfig`). + +### Boot splash & theming + +```yaml +plymouth: + theme: spinner # passed to plymouth-set-default-theme + +grub: + cmdline_extra: "quiet splash" # appended to GRUB_CMDLINE_LINUX_DEFAULT + theme: + source: vinceliuice # vinceliuice | url | none + name: tela # vinceliuice theme: tela|stylish|vimix|whitesur|slaze + # url: https://example.com/theme.tar.gz # used when source: url +``` + +### `desktop` & `kde` + +`desktop.environment` selects which DE stage runs in Phase B. **Only KDE has a built-in stage**; +any other value makes the KDE stage a clean no-op — route that DE's setup through +[`hooks`](#hooks) and your dotfiles instead. + +```yaml +desktop: + environment: kde # kde (default) | gnome | hyprland | sway | none + +kde: + look_and_feel: org.kde.breezedark.desktop + color_scheme: BreezeDark + cursor_theme: breeze_cursors + # wallpaper: /usr/share/wallpapers/Next/contents/images/1920x1080.png +``` + +### Dotfiles + +The dotfiles stage supports a selectable manager. When the `dotfiles:` block is omitted, the +manager defaults to `chezmoi` and the repo falls back to `chezmoi.repo` — so the block is only +needed to pick a different manager or repo. + +```yaml +chezmoi: + repo: https://github.com/AdamJHall/dotfiles + +# dotfiles: +# manager: chezmoi # chezmoi (default) | yadm | bare-git | none +# repo: https://github.com/AdamJHall/dotfiles # defaults to chezmoi.repo when unset +``` + +| `manager` | What it runs | +|-----------|--------------| +| `chezmoi` (default) | `chezmoi init --apply `, or `chezmoi apply` when already initialized | +| `yadm` | `yadm clone `, or `yadm pull` when already cloned | +| `bare-git` | classic bare repo at `~/.dotfiles` with `--work-tree=$HOME` | +| `none` | skip dotfiles entirely (clean no-op) | + +### `setup` steps + +Runs **after** the dotfiles stage. For the things a dotfiles repo references but can't vendor — +oh-my-zsh and its custom plugins, tmux's TPM, theme repos. `steps` is an **ordered** list (top to +bottom); each entry is either a `clone` or a `command`. Order matters: a clone that lands inside +another clone's tree (oh-my-zsh custom plugins) just has to come after it. + +Each `clone` is idempotent — skipped if `dest` already exists (or `git pull`ed when +`update: true`), so the stage is safe to re-run. `~` expands to the user's home. A `command` is +the escape hatch for installers that aren't a git clone. + +```yaml +setup: + steps: + - clone: { url: https://github.com/ohmyzsh/ohmyzsh, dest: ~/.oh-my-zsh } # FIRST + - clone: { url: https://github.com/zsh-users/zsh-autosuggestions, dest: ~/.oh-my-zsh/custom/plugins/zsh-autosuggestions } + - clone: { url: https://github.com/tmux-plugins/tpm, dest: ~/.config/tmux/plugins/tpm } + # - command: curl -sS https://starship.rs/install.sh | sh -s -- -y +``` + +### `services` + +Runs **last** in Phase B (after `dotfiles` and `setup`). `systemctl enable`s the listed units so +they start on the **next boot** — the typical case is a login/display-manager unit that should +take over after reboot rather than be started underneath the current session, so units are +enabled, not `--now`-started. Enabling is idempotent, so the stage is safe to re-run. + +`enable` is system units (enabled as root); `user` is per-user units (enabled with +`systemctl --user`). The `.service` suffix is optional. For one-off needs (or to enable *and* +start a unit now), a `hook` running `systemctl enable --now ` still works. + +```yaml +services: + enable: + - plasmalogin.service # SDDM/Plasma login on next boot + - bluetooth.service + user: + - syncthing.service +``` + +### `hooks` + +The general escape hatch: run your own commands at lifecycle points instead of writing a Go +stage (snap/cargo/gsettings/`systemctl enable`/etc.). Each hook sets exactly one of `run` (an +inline snippet) or `script` (a path to a script file). Hooks are dry-run-aware like everything +else. + +`at` is one of the four global points — `pre-install`, `post-install`, `pre-bootstrap`, +`post-bootstrap` — or a per-stage `before:` / `after:` (stage by name; see +`list-stages`). + +```yaml +hooks: + - name: rust toolchain + at: after:packages + run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + - name: enable bluetooth + at: post-bootstrap + root: true # run privileged + run: systemctl enable --now bluetooth + - name: provision + at: post-bootstrap + script: ~/bin/provision.sh # `~` -> $HOME; existence NOT checked at validate time + dir: ~/work # working directory + env: { PROFILE: desktop } # extra environment variables +``` + +### Environment variables + +Any config **value** may reference an environment variable with `${VAR}` syntax — it is +substituted from the process environment when the config loads. This keeps secrets and +per-machine values out of the (gitignored) file. Rules: + +- Only **values** are expanded — keys and comments are left untouched, so a literal `$` in a + comment is fine. +- An **unset** variable is an error (it names every missing one), not a silent blank. +- Write `$$` for a literal `$` (e.g. inside a shell snippet meant to expand at runtime). + ## Remote & layered configuration -`--config` doesn't have to be a single local file. It can point at a config that lives in -a git repo or at a URL, and that config can pull in and **merge** other configs — so a -machine-specific config stays tiny and sits on top of a shared base. +`--config` doesn't have to be a single local file. It can point at a config that lives in a git +repo or at a URL, and that config can pull in and **merge** other configs — so a machine-specific +config stays tiny and sits on top of a shared base. ```sh archwright install --config github.com/AdamJHall/dotfiles/archwright.desktop.yaml@v1 @@ -116,14 +535,13 @@ A `--config` value (and each `imports:` entry) is one of three forms, told apart | Form | Example | Resolves to | |------|---------|-------------| -| local path | `config.yaml`, `./desktop.yaml` | filesystem read (today's behaviour) | +| local path | `config.yaml`, `./desktop.yaml` | filesystem read | | github shorthand | `github.com/OWNER/REPO/path/to.yaml[@ref]` | `raw.githubusercontent.com/OWNER/REPO//path/to.yaml` | | raw URL | `https://…/file.yaml` | HTTP GET | ### The `imports:` key -A config may carry a top-level `imports:` list naming other configs to merge in *underneath* -it: +A config may carry a top-level `imports:` list naming other configs to merge in *underneath* it: ```yaml # archwright.desktop.yaml (the entry point: desktop-specific) @@ -138,10 +556,10 @@ packages: - steam # added on top of base's packages ``` -A **bare relative path** inside `imports:` resolves against the **importing file's -location**, not your CWD — so a sibling in the same repo is just `archwright.base.yaml`, and -a github-rooted entry point makes its relative imports github-rooted too. `imports:` is -consumed by the resolver and stripped before validation; it is not a config field. +A **bare relative path** inside `imports:` resolves against the **importing file's location**, +not your CWD — so a sibling in the same repo is just `archwright.base.yaml`, and a github-rooted +entry point makes its relative imports github-rooted too. `imports:` is consumed by the resolver +and stripped before validation; it is not a config field. ### Merge precedence @@ -153,8 +571,8 @@ Layering is **base-first, importer-wins**, applied recursively: 4. Imports are processed depth-first; an imported file may itself have `imports:`. So for the example above, effective precedence (low → high) is: -`base.yaml` → `kde.yaml` → `shared.yaml` → `desktop.yaml`. Repeated `--config a --config b` -on the command line is the same merge applied to CLI layers — `b` wins over `a`. +`base.yaml` → `kde.yaml` → `shared.yaml` → `desktop.yaml`. Repeated `--config a --config b` on +the command line is the same merge applied to CLI layers — `b` wins over `a`. ### List fields @@ -166,8 +584,8 @@ Maps merge recursively. Lists are merged per-field by what makes sense for that | name-keyed structured lists | **merge by `name`** (a later layer overrides one entry) | `repos`, `hooks`, `flatpak_remotes` | | identity/layout lists | **replace** | `disks.lvm.pvs`, `disks.btrfs.subvolumes` | -For the rare case where you want to drop everything inherited for one field, the `!replace` -tag is the escape hatch: +For the rare case where you want to drop everything inherited for one field, the `!replace` tag +is the escape hatch: ```yaml packages: !replace [vim, git] # ignore inherited packages, use exactly this @@ -180,25 +598,24 @@ archwright render --config github.com/AdamJHall/dotfiles/archwright.desktop.yaml -o config.flat.yaml ``` -`render` resolves every ref, expands `${VAR}` substitutions, merges all `imports:` and -repeated `--config` layers, and writes the single flattened config (no `imports:`) to `-o`. -It runs no stages and touches no disks — it's the way to preview exactly what a layered -config flattens to, and doubles as the debugging tool for the merge engine. +`render` resolves every ref, expands `${VAR}` substitutions, merges all `imports:` and repeated +`--config` layers, and writes the single flattened config (no `imports:`) to `-o`. It runs no +stages and touches no disks — the way to preview exactly what a layered config flattens to, and +the debugging tool for the merge engine. Phase A resolves and merges **once**, then stages the *flattened* config into the target for -Phase B. Phase B reads a plain local file — no network, no re-fetch — and is guaranteed to -see byte-identical config to Phase A. +Phase B. Phase B reads a plain local file — no network, no re-fetch — guaranteed byte-identical +to what Phase A saw. ### Trust, pinning & caching -Fetching config that drives **destructive disk operations and arbitrary hook commands** from -a URL is a real trust boundary — treat it like one: +Fetching config that drives **destructive disk operations and arbitrary hook commands** from a +URL is a real trust boundary — treat it like one: -- **Pin a ref.** Use `@` on github shorthands; an unpinned `main` warns (and - refuses under `--strict`). When any remote source is in play, Phase A prints the resolved - source list before the `ERASE` confirm, so you see exactly what you're about to run; `render` - prepends the same list as a `# Flattened by archwright from: …` provenance comment (use it to - preview the full merged result). +- **Pin a ref.** Use `@` on github shorthands; an unpinned `main` warns (and refuses + under `--strict`). When any remote source is in play, Phase A prints the resolved source list + before the `ERASE` confirm; `render` prepends the same list as a + `# Flattened by archwright from: …` provenance comment. - **`--offline` uses the cache only.** Fetched files are cached under `$XDG_CACHE_HOME/archwright/` keyed by URL+ref — handy when re-running on a flaky live-ISO network. @@ -206,64 +623,50 @@ a URL is a real trust boundary — treat it like one: github/raw fetches. Keep tokens in the environment, never in the config file (the `${VAR}` substitution already reads them). -## Architecture - -``` -main.go cobra CLI: install / bootstrap / validate -internal/config/ Config struct + tag-based Validate() -internal/archinstall/ render config.yaml -> archinstall config + creds JSON -internal/run/ Runner: Cmd/Shell/Chroot/Root, dry-run, recorded .Plan -internal/ui/ charmbracelet log + lipgloss styling + huh prompts -internal/stages/ one file per stage; self-registering ordered registry -``` - -Stages implement a small interface (`Order/Name/Phase/Run`) and register themselves in -`init()`. The runner records every command into `.Plan`, which is what the tests assert on. -Phase A is just two stages: `preflight` (UEFI + config + archinstall checks) and -`archinstall` (reflector → probe geometry → `internal/archinstall.Build` → write JSON → run -archinstall → **post-install in the target chroot**: custom repos + kernels → stage the -binary for Phase B). The `internal/archinstall` package is independently unit-tested: it -builds the disk/LVM JSON from a config + fake geometry and asserts the layout, the `obj_id` -wiring between PVs and the volume group, and size math — no disks required. - -**Custom repos and kernels are Phase A, not Phase B.** They run in the post-archinstall -chroot so the very first boot already uses them (e.g. boots `linux-cachyos`, with stock -`linux` removed before it ever boots). The repo config is written into the target's -`pacman.conf` + keyring, so it persists and Phase B package installs resolve against it too. -archinstall must always pacstrap stock `linux` for a bootable baseline; `kernel.replace_stock` -removes it in the chroot before reboot. - -## Relationship to dotfiles +## Stages reference + +Every stage is numbered, individually re-runnable, and dry-run-aware. `archwright list-stages` +prints this live; the `--only`/`--skip`/`--from`/`--to` flags and `stages.disable` all match by +name **or** number. + +| # | Stage | Phase | What it does | +|---|-------|-------|--------------| +| 0 | `preflight` | A | UEFI + config + archinstall version checks (warns, doesn't block) | +| 10 | `archinstall` | A | reflector → probe geometry → render JSON → `archinstall --silent` → chroot: repos + kernels → stage the binary for Phase B | +| 10 | `yay` | B | install the AUR helper (`aur_helper`) | +| 20 | `packages` | B | `pacman -S --needed` the official/custom-repo packages | +| 25 | `snapper` | B | provision Snapper (only when btrfs + `snapshots: snapper`) | +| 30 | `flatpak` | B | register `flatpak_remotes`, install `flatpaks` | +| 40 | `aur` | B | build/install the `aur` list via the helper | +| 50 | `plymouth` | B | set the boot splash theme | +| 60 | `grub-theme` | B | apply the GRUB theme + cmdline extras | +| 70 | `kde` | B | KDE look-and-feel / colours / cursor / wallpaper (no-op for other DEs) | +| 80 | `dotfiles` | B | apply dotfiles via the configured manager | +| 85 | `setup` | B | run the ordered `setup.steps` (clones/commands) | +| 90 | `services` | B | `systemctl enable` the `services` units so they start on the next boot | + +(Phase A and Phase B each have their own order numbering — that's why both have a `10`.) -This repo owns the **system**: disks, base OS, packages, boot splash, GRUB/KDE theming. -User-level dotfiles (zsh, terminal, etc.) stay in -[AdamJHall/dotfiles](https://github.com/AdamJHall/dotfiles) and are pulled in by the -`chezmoi` step. - -Things the dotfiles *reference* but can't vendor — oh-my-zsh and its custom plugins, tmux's -TPM, theme repos — are listed under `setup.steps` in `config.yaml` and run by the final -`setup` stage (right after `chezmoi`, so the dotfiles' target dirs already exist). -`setup.steps` is an **ordered** list; each entry is either a `clone` or a `command`, and -they run top to bottom — so a clone that lands inside another clone's tree (e.g. the -oh-my-zsh custom plugins, which need `~/.oh-my-zsh` cloned first) is sequenced just by where -it appears. Each clone is idempotent: skipped if its `dest` already exists, or `git pull`ed -when `update: true`, so the stage is safe to re-run. A `command` is the escape hatch for the -occasional installer that isn't a git clone. See `config.example.yaml` for the full shape. +## Validation -## Testing +Config rules are declared as `validate:` struct tags in +[`internal/config/config.go`](internal/config/config.go) (go-playground/validator), plus +cross-field "semantic" checks for things tags can't express (the right disk sub-block for the +layout, swap/layout compatibility, the `kernel.default ∈ base ∪ packages` rule, `flatpaks` +remotes, …). `validate` reports **every** problem at once with YAML-path messages: -```sh -go test ./... # unit tests: validation table + per-stage command plans -go vet ./... +``` +$ archwright validate --config bad.yaml +disks.esp.device must start with "/dev/" +disks.lvm.filesystem must be one of: xfs ext4 +disks.lvm.pvs must have at least 1 item(s) ``` -Tests run each stage in `--dry-run` and assert on the recorded command plan, so they -verify behavior **without touching disks**. What they cannot cover — real -partitioning/pacstrap/boot — is covered by the VM flow below. - -### Testing in a VM (recommended before real hardware) +## Testing in a VM -Phase A repartitions disks, so smoke-test the whole flow in QEMU with three virtual disks: +**Recommended before real hardware.** Phase A repartitions disks, so smoke-test the whole flow +in QEMU with three virtual disks. This is also where you **validate the generated archinstall +JSON** against the archinstall version on the ISO. ```sh # Three disks: 100G (disk 1: ESP+swap+PV) + 2× 50G (whole-disk PVs) @@ -273,6 +676,7 @@ qemu-img create -f qcow2 disk3.qcow2 50G qemu-system-x86_64 \ -enable-kvm -m 8G -smp 4 \ + -cpu host \ # required: CachyOS repo setup probes CPU features -bios /usr/share/edk2/x64/OVMF.4m.fd \ # UEFI firmware (edk2-ovmf) -drive file=disk1.qcow2,if=virtio \ -drive file=disk2.qcow2,if=virtio \ @@ -281,17 +685,59 @@ qemu-system-x86_64 \ -boot d ``` -Inside the VM the disks appear as `/dev/vda`, `/dev/vdb`, `/dev/vdc` — set -`config.yaml` accordingly (`esp.device: /dev/vda`, PVs `/dev/vda3`, `/dev/vdb`, `/dev/vdc`). -Use `./archwright install --yes` to skip the interactive prompts during automated runs. +Inside the VM the disks appear as `/dev/vda`, `/dev/vdb`, `/dev/vdc` — set `config.yaml` +accordingly (`esp.device: /dev/vda`, PVs `/dev/vda3`, `/dev/vdb`, `/dev/vdc`). Use +`./archwright install --yes` to skip the interactive prompts during automated runs. + +`install --dry-run` prints the rendered config without running anything; a real `install` writes +`/tmp/archinstall-config.json` + `/tmp/archinstall-creds.json` and invokes `archinstall +--silent`. If archinstall rejects the config after a version bump, diff its schema and update +`internal/archinstall` + the pinned `Version`. + +## For contributors + +### Architecture + +``` +main.go cobra CLI: install / bootstrap / validate / render / list-stages + flags +internal/config/ Config struct; Validate() via go-playground/validator struct tags +internal/configsrc/ resolve remote/layered config: --config refs, imports: recursion, + ${VAR} expand, deep-merge -> flattened config +internal/archinstall/ render config.yaml -> archinstall config + creds JSON (Phase A core) +internal/run/ Runner: Cmd/Shell/Chroot/Root/Try, dry-run, recorded .Plan +internal/ui/ stderr-bound lipgloss renderer + log + huh prompts +internal/stages/ one file per stage; self-registering ordered registry +``` + +Stages implement a small interface (`Order`/`Name`/`Phase`/`Run`) and register themselves in +`init()`. `Run` does all side effects through `ctx.R` (the `Runner`), never `os/exec` directly — +that's what makes a stage testable and dry-run-safe. The runner records every command into +`.Plan`, which is what the tests assert on. `internal/archinstall` is independently unit-tested: +it builds the disk/LVM JSON from a config + fake geometry and asserts the layout, the `obj_id` +wiring between PVs and the volume group, and the size math — no disks required. -This VM run is also where you **validate the generated archinstall JSON** against the -version on the ISO. `install --dry-run` prints the rendered config without running anything; -a real `install` writes `/tmp/archinstall-config.json` + `/tmp/archinstall-creds.json` and -invokes `archinstall --silent`. If archinstall rejects the config after a version bump, diff -its schema and update `internal/archinstall` + the pinned `Version`. +### Running the tests -## Releases +```sh +go build -o archwright . # build +go test ./... # unit tests: validation table + per-stage command plans +go vet ./... +``` + +Tests run each stage in `--dry-run` and assert on the recorded command plan, so they verify +behavior **without touching disks**. What they cannot cover — real partitioning/pacstrap/boot — +is covered by the [VM flow](#testing-in-a-vm). + +### Relationship to dotfiles + +This repo owns the **system**: disks, base OS, packages, boot splash, GRUB/KDE theming. +User-level dotfiles (zsh, terminal, etc.) stay in +[AdamJHall/dotfiles](https://github.com/AdamJHall/dotfiles) and are pulled in by the dotfiles +stage. Things the dotfiles *reference* but can't vendor (oh-my-zsh + plugins, tmux's TPM, theme +repos) are listed under [`setup.steps`](#setup-steps) and run by the final `setup` stage, right +after dotfiles so their target dirs already exist. + +### Releases [goreleaser](https://goreleaser.com) builds cross-compiled static binaries: @@ -302,6 +748,7 @@ goreleaser release --clean # publish to GitHub goreleaser check # validate .goreleaser.yaml ``` -Config: [`.goreleaser.yaml`](.goreleaser.yaml) (linux amd64/arm64, version stamped from -the tag, `config.example.yaml` bundled in the archive). -``` +Config: [`.goreleaser.yaml`](.goreleaser.yaml) (linux amd64/arm64, version stamped from the tag, +`config.example.yaml` bundled in the archive). + + diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..337aefe --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,57 @@ +# Convenience targets. The Go workflow stays `go build/test/vet`; these wrap the +# Tier 2 loopback integration harness, which needs root (losetup/lvm/archinstall). +# +# Tooling is managed by mise (see mise.toml): `mise install` provisions Go + task, +# then `task ` or `mise run ` drives the workflow. +version: '3' + +vars: + # e2e disk-layout knobs, overridable on the CLI: + # task e2e-disks-light LAYOUT=single-disk-lvm FS=ext4 + LAYOUT: '{{.LAYOUT | default "multi-disk-lvm"}}' + FS: '{{.FS | default "xfs"}}' + +tasks: + build: + desc: Build the archwright binary. + cmds: + - go build -o archwright . + + test: + desc: Run unit tests (validation table + per-stage command plans). + cmds: + - go test ./... + + vet: + desc: Run go vet. + cmds: + - go vet ./... + + e2e-disks-light: + desc: >- + Validate archwright's rendered archinstall JSON against a real archinstall + (--dry-run) on loop devices. No install, no network. + cmds: + - sudo bash test/e2e/disks.sh --mode light --layout {{.LAYOUT}} --fs {{.FS}} + + e2e-disks-full: + desc: >- + Real partition/format/pacstrap onto loop devices, then assert layout. + Slow; needs network + disk space. + cmds: + - sudo bash test/e2e/disks.sh --mode full --layout {{.LAYOUT}} --fs {{.FS}} --disk1-size 12G --extra-size 6G + + vm: + desc: Interactive QEMU smoke test — boot the Arch live ISO (run Phase A). + cmds: + - bash test/vm.sh iso + + vm-fresh: + desc: Boot the Arch live ISO, wiping the virtual disks first. + cmds: + - bash test/vm.sh iso --fresh + + vm-disk: + desc: Boot the installed system off disk 1. + cmds: + - bash test/vm.sh disk diff --git a/config.example.yaml b/config.example.yaml index 164e904..65520c2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -294,6 +294,19 @@ setup: # Example of a non-clone installer (runs in place, between/after clones): # - command: curl -sS https://starship.rs/install.sh | sh -s -- -y +# --- services (optional) ---------------------------------------------------- +# systemd units to `systemctl enable` last (after dotfiles + setup) so they +# start on the next boot. `enable` is system units (enabled as root); `user` is +# per-user units (enabled with `systemctl --user`). The .service suffix is +# optional. Units are enabled, not started underneath the current session — the +# common case is a login/display-manager unit that takes over after reboot. +# services: +# enable: +# - plasmalogin.service # SDDM/Plasma login on next boot +# - bluetooth.service +# user: +# - syncthing.service + # --- hooks (optional) ------------------------------------------------------- # Run your own commands at lifecycle points instead of writing a Go stage — the # general escape hatch for snap/cargo/gsettings/etc. `at` is one of the global diff --git a/internal/config/config.go b/internal/config/config.go index 20cccb1..1e4913b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -110,6 +110,11 @@ type Config struct { Setup SetupConfig `yaml:"setup"` + // Services lists systemd units to enable in Phase B so they start on the next + // boot (e.g. a display-manager unit like plasmalogin.service). Enabling is + // idempotent, so the stage is safe to re-run. Unset = no services touched. + Services ServicesConfig `yaml:"services"` + Hooks []Hook `yaml:"hooks" validate:"dive"` // Bootloader selects which bootloader Phase A installs and Phase B configures. @@ -270,6 +275,17 @@ type Clone struct { Update bool `yaml:"update"` } +// ServicesConfig drives the Phase B 90-services stage, which runs last (after +// dotfiles + setup) and `systemctl enable`s the listed units so they start on +// the next boot. Enable holds system units (enabled as root); User holds +// per-user units (enabled with `systemctl --user`, unprivileged). Both default +// to empty, so an unset block touches no services. The trailing ".service" is +// optional — systemctl accepts a bare unit name. +type ServicesConfig struct { + Enable []string `yaml:"enable"` // system units: systemctl enable ... + User []string `yaml:"user"` // user units: systemctl --user enable ... +} + // Hook is a user-defined command run at a named lifecycle point. Exactly one of // Run (an inline shell snippet) or Script (a path to a script file) is set. // Script and Dir have a leading `~` expanded to the user's home at run time. diff --git a/internal/stages/fromto_test.go b/internal/stages/fromto_test.go index 7607806..3329eed 100644 --- a/internal/stages/fromto_test.go +++ b/internal/stages/fromto_test.go @@ -27,12 +27,12 @@ func TestWithin(t *testing.T) { { name: "from only by name is inclusive lower bound", from: "flatpak", to: "", - want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup"}, + want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"}, }, { name: "from only by number resolves same as name", from: "30", to: "", - want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup"}, + want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"}, }, { name: "to only by name is inclusive upper bound", diff --git a/internal/stages/services.go b/internal/stages/services.go new file mode 100644 index 0000000..404f20b --- /dev/null +++ b/internal/stages/services.go @@ -0,0 +1,43 @@ +package stages + +import "github.com/AdamJHall/archwright/internal/ui" + +// services is the Phase B 90-services stage: enable the configured systemd units +// so they start on the next boot. It runs last (after dotfiles + setup) so any +// unit a dotfiles/setup step installs is already present to be enabled. +// +// System units are enabled as root (`systemctl enable`); user units are enabled +// unprivileged (`systemctl --user enable`). Enabling is idempotent, so the stage +// is safe to re-run. We deliberately don't `--now`-start them: the typical case +// is a login/display-manager unit (e.g. plasmalogin.service) that should take +// over on the next boot, not be started underneath the current session. +type services struct{} + +func init() { register(services{}) } + +func (services) Order() int { return 90 } +func (services) Name() string { return "services" } +func (services) Phase() Phase { return Bootstrap } + +func (services) Run(ctx *Context) error { + sys := ctx.Cfg.Services.Enable + usr := ctx.Cfg.Services.User + if len(sys) == 0 && len(usr) == 0 { + ui.Warn("no services in config — skipping") + return nil + } + + if len(sys) > 0 { + if err := ctx.R.Root("systemctl", append([]string{"enable"}, sys...)...); err != nil { + return err + } + } + if len(usr) > 0 { + if err := ctx.R.Cmd("systemctl", append([]string{"--user", "enable"}, usr...)...); err != nil { + return err + } + } + + ui.OK("services enabled") + return nil +} diff --git a/internal/stages/services_test.go b/internal/stages/services_test.go new file mode 100644 index 0000000..a0bd5b5 --- /dev/null +++ b/internal/stages/services_test.go @@ -0,0 +1,47 @@ +package stages + +import ( + "strings" + "testing" +) + +// The services stage (90) enables system units as root and user units with +// `systemctl --user`, so they start on the next boot. These assert the recorded +// dry-run plan against an inline config. + +func TestServices_SystemAndUser(t *testing.T) { + plan := planForCfg(t, Bootstrap, "services", ` +services: + enable: + - plasmalogin.service + - bluetooth.service + user: + - syncthing.service +`) + mustContain(t, plan, + // system units enabled in one privileged systemctl call (Sudo in Phase B) + "sudo systemctl enable plasmalogin.service bluetooth.service", + // user units enabled unprivileged via --user + "systemctl --user enable syncthing.service", + ) +} + +func TestServices_EmptySkips(t *testing.T) { + // No services block: the stage is a clean no-op — nothing planned. + plan := planForCfg(t, Bootstrap, "services", "{}\n") + if joined := strings.Join(plan, "\n"); strings.Contains(joined, "systemctl") { + t.Errorf("no systemctl call expected when services is unset; plan:\n%s", joined) + } +} + +func TestServices_SystemOnly(t *testing.T) { + // Only system units set: no stray `systemctl --user` call. + plan := planForCfg(t, Bootstrap, "services", ` +services: + enable: [docker.service] +`) + mustContain(t, plan, "sudo systemctl enable docker.service") + if joined := strings.Join(plan, "\n"); strings.Contains(joined, "--user") { + t.Errorf("no --user call expected when only system units set; plan:\n%s", joined) + } +} diff --git a/internal/stages/stages_test.go b/internal/stages/stages_test.go index 1a0c51f..50e0753 100644 --- a/internal/stages/stages_test.go +++ b/internal/stages/stages_test.go @@ -148,8 +148,8 @@ func TestRegistry(t *testing.T) { []string{"preflight", "archinstall"}, []int{0, 10}) check(Bootstrap, - []string{"yay", "packages", "snapper", "flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup"}, - []int{10, 20, 25, 30, 40, 50, 60, 70, 80, 85}) + []string{"yay", "packages", "snapper", "flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"}, + []int{10, 20, 25, 30, 40, 50, 60, 70, 80, 85, 90}) } func TestPlan_Archinstall(t *testing.T) { diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..d0d1092 --- /dev/null +++ b/mise.toml @@ -0,0 +1,6 @@ +# Tooling for archwright development. `mise install` provisions everything; +# `mise run ` (or `task `) drives the workflow. Go stays the build +# tool — these wrap it plus the root-only loopback/QEMU integration harness. +[tools] +go = "1.26" +task = "latest"