diff --git a/README.md b/README.md index eaa9184..e2be698 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. @@ -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 @@ -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`.) diff --git a/Taskfile.yml b/Taskfile.yml index b3150a2..c3e6845 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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}}' diff --git a/internal/run/run.go b/internal/run/run.go index fb63d4e..52466bb 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -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 ` + // 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 @@ -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) @@ -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 ` 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 --` (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