Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 25 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ 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.
the bootloader. Then, in the post-install chroot, it sets up custom repos and kernels and runs
the bulk of customization — packages, flatpaks, GRUB/KDE theming, dotfiles and the `setup`
steps (user-space steps run as your user via `sudo -iu`) — so your **first boot is already a
set-up desktop**. Finally it stages the binary + flattened config for Phase B.
- **`bootstrap` (Phase B)** — on the booted system, as **your user**. What genuinely needs a
booted session: the AUR helper + `aur` packages (`makepkg` can't run as root) and btrfs
snapshots. Re-runnable any time to reconcile those.

> **⚠️ archinstall version coupling.** archinstall's JSON config is *not* a stable API; its
> schema changes between releases. Archwright renders against the version pinned in
Expand Down Expand Up @@ -333,12 +335,12 @@ 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 |
| `pacstrap` | **Phase A**, by archinstall, verbatim | the minimum the system needs to *boot* — 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 |
| `packages` | **Phase A** chroot, `pacman -S --needed`, after repos | everything else from the official (and custom) repos — the desktop, tools, etc. |
| `aur` | **Phase B**, via the AUR helper | AUR packages (e.g. `1password`) — `makepkg` can't run as root, so this stays post-boot |
| `flatpaks` | **Phase A** chroot, as the user (`--user`) | 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.
Expand Down Expand Up @@ -430,7 +432,7 @@ stage order.

### `desktop` & `kde`

`desktop.environment` selects which DE stage runs in Phase B. **Only KDE has a built-in stage**;
`desktop.environment` selects which DE stage runs (in Phase A's chroot). **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.

Expand Down Expand Up @@ -487,9 +489,9 @@ setup:

### `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
Runs **last** among the Phase A customization stages (after `dotfiles` and `setup`). `systemctl
enable`s the listed units so they start on **first boot** — the typical case is a login/display-
manager unit that should take over on boot rather than be started underneath the live ISO, 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
Expand Down Expand Up @@ -656,17 +658,19 @@ 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 (incl. `plymouth` boot splash) → `archinstall --silent` → chroot: repos + kernels → stage the binary for Phase B |
| 10 | `archinstall` | A | reflector → probe geometry → render JSON (incl. `plymouth` boot splash) → `archinstall --silent` → chroot: repos + kernels → leaves the target mounted |
| 20 | `mount` | A | ensure the target mount tree at `/mnt` and open the chroot for the customization stages that follow |
| 30 | `packages` | A | `pacman -S --needed` the official/custom-repo packages (in the chroot) |
| 40 | `flatpak` | A | register `flatpak_remotes`, install `flatpaks` (as the user, `--user`) |
| 50 | `grub-theme` | A | apply the GRUB theme |
| 60 | `kde` | A | KDE global theme via `LookAndFeelPackage` in kdeglobals (no-op for other DEs) |
| 70 | `dotfiles` | A | apply dotfiles via the configured manager (as the user) |
| 80 | `setup` | A | run the ordered `setup.steps` (clones/commands, as the user) |
| 90 | `services` | A | `systemctl enable` the `services` units so they start on first boot |
| 99 | `finalize` | A | stage the binary + flattened config into the user's home, then unmount |
| 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 |
| 60 | `grub-theme` | B | apply the GRUB theme |
| 70 | `kde` | B | KDE global theme via `LookAndFeelPackage` in kdeglobals (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`.)

Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ vars:
# Arch live ISO for the QEMU smoke test. Cached under the gitignored .iso/ dir
# and pinned to a dated archive build so the download is reproducible. Bump
# ISO_VERSION (CLI-overridable) in lockstep with the version in test/vm.sh.
ISO_VERSION: '{{.ISO_VERSION | default "2026.06.01"}}'
ISO_VERSION: '{{.ISO_VERSION | default "2026.07.01"}}'
ISO_FILE: 'archlinux-{{.ISO_VERSION}}-x86_64.iso'
ISO_PATH: '.iso/{{.ISO_FILE}}'

Expand Down
149 changes: 117 additions & 32 deletions internal/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ type Runner struct {
DryRun bool // print/record commands instead of executing them
Sudo bool // prefix privileged commands with sudo (Phase B as user); false in Phase A (already root)

// ChrootRoot, when set, routes every command through `arch-chroot <ChrootRoot>`
// so a stage's existing Cmd/Root/Shell calls execute inside the installed target
// (Phase A customization). AsUser, when set alongside it, runs *unprivileged*
// commands (Cmd/Shell) as that user inside the target via `sudo -iu` — root→user
// needs no password and -i supplies the user's login env. Both are empty in the
// normal (host) execution path, where behaviour is byte-identical to before.
ChrootRoot string
AsUser string

// Env, when non-empty, is layered (key=value) on top of the inherited process
// environment for every executed command. Dir, when set, is the working dir.
Env map[string]string
Expand Down Expand Up @@ -46,8 +55,17 @@ func (r *Runner) prepare(cmd *exec.Cmd) {

// Cmd runs a program with args, streaming its output (we deliberately do not
// hide it behind a spinner so installer progress and errors stay visible). In
// dry-run it only logs and records.
// dry-run it only logs and records. With a chroot target set it runs inside the
// target (as AsUser when set) — see chrootWrap.
func (r *Runner) Cmd(name string, args ...string) error {
name, args = r.chrootWrap(false, name, args)
return r.exec(name, args)
}

// exec records and runs a fully-resolved command — no privilege or chroot wrapping
// is applied here. It is the shared tail of Cmd/Root/Chroot once that wrapping has
// been decided, so the recorded `.Plan` line is exactly what executes.
func (r *Runner) exec(name string, args []string) error {
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
r.record(line)
ui.Step("%s", line)
Expand All @@ -63,32 +81,77 @@ func (r *Runner) Cmd(name string, args ...string) error {
return nil
}

// Root runs a command with root privileges: directly when already root (Phase A,
// live ISO) or via sudo otherwise (Phase B, as the user).
// chrootWrap prefixes a command with `arch-chroot <ChrootRoot>` when a chroot
// target is set. For an unprivileged command (priv=false) with AsUser set it also
// runs as that user inside the target via `sudo -iu <user> --` (root→user needs no
// password; -i gives the login env). With no chroot target it returns name/args
// unchanged — Phase A is already root, so no live-ISO sudo is added here (Root
// handles the host sudo path separately).
func (r *Runner) chrootWrap(priv bool, name string, args []string) (string, []string) {
if r.ChrootRoot == "" {
return name, args
}
pre := []string{r.ChrootRoot}
if !priv && r.AsUser != "" {
pre = append(pre, "sudo", "-iu", r.AsUser, "--")
}
pre = append(pre, name)
return "arch-chroot", append(pre, args...)
}

// Root runs a command with root privileges: inside the chroot target via
// arch-chroot when one is set (Phase A customization, already root), via sudo when
// running as the user (Phase B), or directly when already root with no chroot.
func (r *Runner) Root(name string, args ...string) error {
if r.ChrootRoot != "" {
name, args = r.chrootWrap(true, name, args)
return r.exec(name, args)
}
if r.Sudo {
return r.Cmd("sudo", append([]string{name}, args...)...)
return r.exec("sudo", append([]string{name}, args...))
}
return r.Cmd(name, args...)
return r.exec(name, args)
}

// RootShell runs a shell script with root privileges through `bash -c`: via
// `sudo bash -c <script>` when not already root (Phase B, as the user) or
// directly when already root (Phase A, live ISO). This lets a shell pipeline
// run wholly as root without inner per-command `sudo`. It records/prints the
// same `sh: <script>` line as Shell so the recorded .Plan stays uniform.
// RootShell runs a shell script with root privileges through `bash -c`. See shell:
// it records the plain `sh: <script>` line in the un-chrooted path (uniform with
// Shell) and the full wrapped command when running in a chroot target.
func (r *Runner) RootShell(script string) error {
r.record("sh: " + script)
ui.Step("sh: %s", script)
return r.shell(true, script)
}

// shell is the shared body of Shell (unprivileged) and RootShell (privileged): it
// resolves the bash invocation — wrapped in arch-chroot (and `sudo -iu <user>` for
// an unprivileged shell) when a chroot target is set, via sudo when privileged and
// running as the user, or a bare `bash -c` otherwise — then records and runs it.
func (r *Runner) shell(priv bool, script string) error {
var name string
var args []string
switch {
case r.ChrootRoot != "":
a := []string{r.ChrootRoot}
if !priv && r.AsUser != "" {
a = append(a, "sudo", "-iu", r.AsUser)
}
name, args = "arch-chroot", append(a, "bash", "-c", script)
case priv && r.Sudo:
name, args = "sudo", []string{"bash", "-c", script}
default:
name, args = "bash", []string{"-c", script}
}
// Record the plain `sh: <script>` line on the host path (keeps existing plans
// stable); record the full command when chrooted so the plan shows the
// arch-chroot/user prefix that actually runs.
rec := "sh: " + script
if r.ChrootRoot != "" {
rec = "sh: " + strings.TrimSpace(name+" "+strings.Join(args, " "))
}
r.record(rec)
ui.Step("%s", rec)
if r.DryRun {
return nil
}
var cmd *exec.Cmd
if r.Sudo {
cmd = exec.Command("sudo", "bash", "-c", script)
} else {
cmd = exec.Command("bash", "-c", script)
}
cmd := exec.Command(name, args...)
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
r.prepare(cmd)
if err := cmd.Run(); err != nil {
Expand All @@ -113,21 +176,43 @@ func (r *Runner) TryRoot(name string, args ...string) {
// Shell runs a string through `bash -c`, for pipes/redirects/conditionals
// (genfstab, idempotent sed edits, gpg key import). Prefer Cmd where possible.
func (r *Runner) Shell(script string) error {
r.record("sh: " + script)
ui.Step("sh: %s", script)
if r.DryRun {
return nil
}
cmd := exec.Command("bash", "-c", script)
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
r.prepare(cmd)
if err := cmd.Run(); err != nil {
return fmt.Errorf("shell: %w", err)
}
return nil
return r.shell(false, script)
}

// Chroot runs a command inside arch-chroot at root (Phase A live ISO).
// Chroot runs a command inside arch-chroot at root (Phase A live ISO). It is the
// explicit-chroot primitive, so it bypasses the ChrootRoot wrapping to avoid
// double-wrapping when a chroot target happens to be set.
func (r *Runner) Chroot(root string, args ...string) error {
return r.Cmd("arch-chroot", append([]string{root}, args...)...)
return r.exec("arch-chroot", append([]string{root}, args...))
}

// Has reports whether bin is available on PATH in the active execution context —
// inside the chroot target when set, else on the host. Read-only: it is not
// recorded in the plan. Under dry-run with a chroot target there is nothing
// mounted to probe, so it returns false (which makes a stage plan the install
// branch).
func (r *Runner) Has(bin string) bool {
if r.ChrootRoot != "" {
if r.DryRun {
return false
}
return exec.Command("arch-chroot", r.ChrootRoot, "sh", "-c", "command -v "+bin+" >/dev/null 2>&1").Run() == nil
}
_, err := exec.LookPath(bin)
return err == nil
}

// PathExists reports whether path exists in the active execution context — inside
// the chroot target when set, else on the host. Read-only: not recorded. Under
// dry-run with a chroot target it returns false so a stage plans the create/clone
// branch.
func (r *Runner) PathExists(path string) bool {
if r.ChrootRoot != "" {
if r.DryRun {
return false
}
return exec.Command("arch-chroot", r.ChrootRoot, "test", "-e", path).Run() == nil
}
_, err := os.Stat(path)
return err == nil
}
63 changes: 63 additions & 0 deletions internal/run/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,66 @@ func TestChroot_RecordsArchChrootPrefix(t *testing.T) {
t.Errorf("Plan = %v, want %v", r.Plan, want)
}
}

// These tests pin the chroot-target seam the Phase A customization stages rely on:
// with ChrootRoot set, a stage's existing Cmd/Root/Shell/RootShell calls must be
// transparently wrapped in arch-chroot — privileged ones as root, unprivileged
// ones as AsUser via `sudo -iu` — without the stage code changing.

func TestCmd_ChrootAsUserWrapsSudoLogin(t *testing.T) {
r := &Runner{DryRun: true, ChrootRoot: "/mnt", AsUser: "adam"}
if err := r.Cmd("flatpak", "--user", "install", "-y", "flathub", "org.x"); err != nil {
t.Fatalf("dry-run Cmd should not error: %v", err)
}
want := []string{"arch-chroot /mnt sudo -iu adam -- flatpak --user install -y flathub org.x"}
if !reflect.DeepEqual(r.Plan, want) {
t.Errorf("Plan = %v, want %v", r.Plan, want)
}
}

func TestRoot_ChrootRunsAsRootNoSudo(t *testing.T) {
// Privileged commands run as root inside the chroot — no `sudo -iu`, no live-ISO
// sudo (Phase A is already root).
r := &Runner{DryRun: true, ChrootRoot: "/mnt", AsUser: "adam"}
if err := r.Root("pacman", "-S", "--needed", "--noconfirm", "kwrite"); err != nil {
t.Fatalf("dry-run Root should not error: %v", err)
}
want := []string{"arch-chroot /mnt pacman -S --needed --noconfirm kwrite"}
if !reflect.DeepEqual(r.Plan, want) {
t.Errorf("Plan = %v, want %v", r.Plan, want)
}
}

func TestShell_ChrootAsUserRecordsWrappedCommand(t *testing.T) {
r := &Runner{DryRun: true, ChrootRoot: "/mnt", AsUser: "adam"}
if err := r.Shell("curl -fsSL https://x | sh"); err != nil {
t.Fatalf("dry-run Shell should not error: %v", err)
}
want := []string{"sh: arch-chroot /mnt sudo -iu adam bash -c curl -fsSL https://x | sh"}
if !reflect.DeepEqual(r.Plan, want) {
t.Errorf("Plan = %v, want %v", r.Plan, want)
}
}

func TestRootShell_ChrootRunsAsRoot(t *testing.T) {
r := &Runner{DryRun: true, ChrootRoot: "/mnt", AsUser: "adam"}
if err := r.RootShell("grub-mkconfig -o /boot/grub/grub.cfg"); err != nil {
t.Fatalf("dry-run RootShell should not error: %v", err)
}
want := []string{"sh: arch-chroot /mnt bash -c grub-mkconfig -o /boot/grub/grub.cfg"}
if !reflect.DeepEqual(r.Plan, want) {
t.Errorf("Plan = %v, want %v", r.Plan, want)
}
}

func TestHasAndPathExists_DryRunChrootReturnFalse(t *testing.T) {
// Under dry-run with a chroot target there is nothing mounted to probe, so the
// probes return false (a stage then plans the install/clone branch).
r := &Runner{DryRun: true, ChrootRoot: "/mnt"}
if r.Has("definitely-not-a-real-binary") {
t.Error("Has should be false for a chroot probe under dry-run")
}
if r.PathExists("/nope/does/not/exist") {
t.Error("PathExists should be false for a chroot probe under dry-run")
}
}
Loading
Loading