⚠️ Heads up: this is vibe coded and probably trash. It's opinionated, not intended 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) — 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, chezmoi, …), designed for the "I had to reinstall again" workflow.
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 yourconfig.yamlinto 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-definedsetupstep.
⚠️ archinstall version coupling. archinstall's JSON config is not a stable API; its schema changes between releases. Archwright renders against the version pinned ininternal/archinstall(Version), andpreflightonly 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) before trusting it on hardware. After any archinstall version bump, diff the schema and update that package +Versiontogether.
- Quick start
- Installing the binary
- Commands & flags
- Configuration reference
- Remote & layered configuration
- Stages reference
- Validation
- Testing in a VM
- For contributors
# 1. Configure
cp config.example.yaml config.yaml
$EDITOR config.yaml # set disks, hostname, user, packages, themes
./archwright validate # sanity-check the config first
# 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 bootstrapconfig.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.
go build -o archwright . # local build
go install ./... # into $GOBIN
# stamped build (what releases do):
go build -ldflags "-s -w -X main.version=$(git describe --tags --always)" -o archwright .Or grab a prebuilt binary from a GitHub release.
archwright install [flags] [--yes]
archwright bootstrap [flags]
archwright validate [flags]
archwright render [flags] [-o <out.yaml>]
archwright list-stages
archwright --version
| 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 |
list-stages |
— | anyone | print every registered stage with its order, name and phase (the source of truth for --only/--skip/stages.disable) |
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) |
--config <ref> |
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 |
--offline |
resolve remote refs from the local cache only (no network) |
--strict |
refuse unpinned github refs (require @ref) |
--no-color |
disable coloured output (NO_COLOR is also honoured) |
Four flags slice the stage list (all match a stage by name or number — see list-stages):
| Flag | Effect |
|---|---|
--only <stage> |
run a single stage (--only 20, --only packages) |
--skip <stage> |
skip a stage; repeatable (--skip plymouth --skip grub-theme) |
--from <stage> |
resume from a stage onwards (inclusive) |
--to <stage> |
stop after a stage (inclusive) |
To skip stages persistently (without passing flags every run), list them under
stages.disable in the config. A --only on the command line overrides
both --skip and stages.disable.
| Flag | Effect |
|---|---|
--yes |
skip the destructive ERASE confirmation and set a throwaway password — VMs / automation only |
| Flag | Effect |
|---|---|
-o, --output <file> |
where to write the flattened config (default stdout; - is also stdout) |
config.yaml is the whole interface. Every value may reference an environment variable with
${VAR} syntax (see Environment variables). The struct in
internal/config/config.go is the schema — the
config.example.yaml is an annotated, copy-ready instance of everything
below.
Base OS identity, generated in Phase A's chroot.
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)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).
user:
name: adam
shell: /usr/bin/zsh # must start with /
groups: [wheel] # supplementary groups; `wheel` grants sudowheel is what gives the user sudo (configured during install) — needed because Phase B runs
as the user and escalates with sudo.
Skip stages without emptying their config blocks — the persisted equivalent of --skip. Match
by stage name or number (see archwright list-stages).
stages:
disable: [plymouth, grub-theme]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.
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 diskswap.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.
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 /.
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 VGdisks:
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 in Phase B (it is
a no-op for any other layout/setting).
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 | xfsOmit for no encryption (the default). The LUKS passphrase is the same install password archwright already collects — it is not stored in the config.
disks:
encryption:
type: lvm_on_luks # encrypt the PV partitions, LVM on topencryption.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_luks2-PV limit and exact archinstall behaviour are reverse-engineered and VM-validation-pending — confirm in a VM before relying on it.
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.
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)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.
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) | paruWhy 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 (bootslinux-cachyos, with stocklinuxremoved before it ever boots) and Phase B installs resolve against the custom repos too. archinstall must always pacstrap a stocklinuxfor a bootable baseline;kernel.replace_stockremoves it in the chroot before reboot.
bootloader:
kind: grub # grub (default) | systemd-bootgrub 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).
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: urldesktop.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 and your dotfiles instead.
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.pngThe 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.
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 unsetmanager |
What it runs |
|---|---|
chezmoi (default) |
chezmoi init --apply <repo>, or chezmoi apply when already initialized |
yadm |
yadm clone <repo>, or yadm pull when already cloned |
bare-git |
classic bare repo at ~/.dotfiles with --work-tree=$HOME |
none |
skip dotfiles entirely (clean no-op) |
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 pulled 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.
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 -- -yRuns last in Phase B (after dotfiles and setup). systemctl enables 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 <unit> still works.
services:
enable:
- plasmalogin.service # SDDM/Plasma login on next boot
- bluetooth.service
user:
- syncthing.serviceThe 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:<stage> / after:<stage> (stage by name; see
list-stages).
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 variablesAny 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).
--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.
archwright install --config github.com/AdamJHall/dotfiles/archwright.desktop.yaml@v1A --config value (and each imports: entry) is one of three forms, told apart by shape:
| Form | Example | Resolves to |
|---|---|---|
| local path | config.yaml, ./desktop.yaml |
filesystem read |
| github shorthand | github.com/OWNER/REPO/path/to.yaml[@ref] |
raw.githubusercontent.com/OWNER/REPO/<ref-or-default>/path/to.yaml |
| raw URL | https://…/file.yaml |
HTTP GET |
A config may carry a top-level imports: list naming other configs to merge in underneath it:
# archwright.desktop.yaml (the entry point: desktop-specific)
imports:
- archwright.base.yaml # sibling, resolved next to THIS file
- github.com/AdamJHall/dotfiles/archwright.kde.yaml@v1 # another file / repo, pinned
- https://example.com/teams/shared.yaml # raw URL
system:
hostname: desktop-box # overrides whatever base set
packages:
- steam # added on top of base's packagesA 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.
Layering is base-first, importer-wins, applied recursively:
- An imported file is resolved and merged before the file that imports it.
- Among multiple
imports:, later entries override earlier ones. - The importing file's own top-level keys override everything it imported.
- 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.
Maps merge recursively. Lists are merged per-field by what makes sense for that field:
| Field shape | Strategy | Examples |
|---|---|---|
| plain string lists | union + dedup | packages, flatpaks, aur, system.locales, user.groups |
| 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:
packages: !replace [vim, git] # ignore inherited packages, use exactly thisarchwright render --config github.com/AdamJHall/dotfiles/archwright.desktop.yaml@v1 \
-o config.flat.yamlrender 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 — guaranteed byte-identical to what Phase A saw.
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
@<tag-or-sha>on github shorthands; an unpinnedmainwarns (and refuses under--strict). When any remote source is in play, Phase A prints the resolved source list before theERASEconfirm;renderprepends the same list as a# Flattened by archwright from: …provenance comment. --offlineuses 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.- Private repos. Set
GITHUB_TOKENand it's sent as theAuthorizationheader for github/raw fetches. Keep tokens in the environment, never in the config file (the${VAR}substitution already reads them).
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.)
Config rules are declared as validate: struct tags in
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:
$ 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)
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.
# Three disks: 100G (disk 1: ESP+swap+PV) + 2× 50G (whole-disk PVs)
qemu-img create -f qcow2 disk1.qcow2 100G
qemu-img create -f qcow2 disk2.qcow2 50G
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 \
-drive file=disk3.qcow2,if=virtio \
-cdrom archlinux-x86_64.iso \
-boot dInside 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.
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.
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.
This repo owns the system: disks, base OS, packages, boot splash, GRUB/KDE theming.
User-level dotfiles (zsh, terminal, etc.) stay in
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 and run by the final setup stage, right
after dotfiles so their target dirs already exist.
goreleaser builds cross-compiled static binaries:
goreleaser release --snapshot --clean # local test, no publish
git tag v0.1.0 && git push origin v0.1.0
goreleaser release --clean # publish to GitHub
goreleaser check # validate .goreleaser.yamlConfig: .goreleaser.yaml (linux amd64/arm64, version stamped from the tag,
config.example.yaml bundled in the archive).