Complete optional setup and boot restoration workflow

This commit is contained in:
fi3w0 2026-10-03 05:51:38 +02:00
parent 09503bf948
commit 8d59cc4f06
15 changed files with 1204 additions and 120 deletions

View file

@ -1,5 +1,7 @@
<div align="center"> <div align="center">
<img src="assets/images/Gentoo.png" alt="Gentoo logo" width="110" />
# Fiw-Gentoo-Dots # Fiw-Gentoo-Dots
**My Gentoo setup, ready to make home feel like home again.** **My Gentoo setup, ready to make home feel like home again.**
@ -29,7 +31,7 @@ anything is applied.
- **Keep your edits:** conflict prompts, backups and `.new` proposals on updates. - **Keep your edits:** conflict prompts, backups and `.new` proposals on updates.
**Work in progress:** configuration restoration and the TUI are ready for review. **Work in progress:** configuration restoration and the TUI are ready for review.
Package installation needs a supervised first run on a fresh Gentoo system. Package and boot execution need a supervised first run on a fresh Gentoo system.
The existing installation has not been changed by creating this repo. The existing installation has not been changed by creating this repo.
## Start ## Start
@ -41,10 +43,12 @@ cd Fiw-Gentoo-Dots
``` ```
The Bubble Tea interface offers a preset, package sections, individual app The Bubble Tea interface offers a preset, package sections, individual app
configs, kernel/bootloader choices, extras, and a final preview. Enter at the configs, kernel/bootloader choices, individual Flatpaks, optional services and
preview saves a selection without applying it. `a` restores configs; `i` a final preview. Enter saves without applying. `r` runs the full restore;
starts the package installation workflow with sudo. Go 1.24+ builds the TUI; `a` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
Python 3.11+ runs the backend. Go dependencies are recorded in go.mod/go.sum. `v` enables selected services and `b` deploys the selected bootloader.
Go 1.24+ builds the TUI; Python 3.11+ runs the backend. Go dependencies are
recorded in go.mod/go.sum. Saved selections are reused on subsequent launches.
For a preview without building the TUI: For a preview without building the TUI:
@ -85,9 +89,11 @@ CachyOS 7.2.7-1 patch. Refresh it explicitly when updating the snapshot.
| [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities | | [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities |
Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not
require Btrfs tools. LocalSend is an optional Flatpak in the apps section. require Btrfs tools. LocalSend and Sober are individually optional Flatpaks
in the TUI, grouped with apps and gaming respectively.
Limine and GRUB are optional alternatives; retaining the current loader is Limine and GRUB are optional alternatives; retaining the current loader is
the default. Kernel choice is independent of bootloader choice. the default. Kernel choice is independent of bootloader choice. Deployment
previews the target ESP and firmware choices; see [boot setup](docs/boot.md).
## Configs ## Configs
@ -136,6 +142,10 @@ python3 -m unittest discover -s tests
go test ./... go test ./...
``` ```
Missing selected overlays are staged before package resolution. Optional
service presets enable audio, networking, Bluetooth and power profiles for
the next boot/login. See [setup](docs/setup.md) and the [helper list](docs/scripts.md).
Review captures before committing. `local/` and built binaries are ignored. Review captures before committing. `local/` and built binaries are ignored.
This repo starts with fresh history. Third-party theme/cursor assets retain This repo starts with fresh history. Third-party theme/cursor assets retain
their licence notices; overlay ebuilds retain their original notices. their licence notices; overlay ebuilds retain their original notices.

BIN
assets/images/Arch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
assets/images/Gentoo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -22,15 +22,25 @@ type selection struct {
Extras []string `json:"extras"` Extras []string `json:"extras"`
Firefox bool `json:"firefox_privacy"` Firefox bool `json:"firefox_privacy"`
Spotify bool `json:"spotify_custom"` Spotify bool `json:"spotify_custom"`
Flatpaks []string `json:"flatpaks"`
Services []string `json:"services"`
} }
type config struct { type config struct {
Label string `json:"label"` Label string `json:"label"`
Group string `json:"group"` Group string `json:"group"`
} }
type setupOption struct {
Label string `json:"label"`
Group string `json:"group"`
Scope string `json:"scope"`
}
type catalog struct { type catalog struct {
Groups []string `json:"groups"` Groups []string `json:"groups"`
Configs map[string]config `json:"configs"` Configs map[string]config `json:"configs"`
Extras map[string]string `json:"extras"` Extras map[string]string `json:"extras"`
Flatpaks map[string]setupOption `json:"flatpaks"`
Services map[string]setupOption `json:"services"`
Stock selection `json:"stock"` Stock selection `json:"stock"`
Ryzen selection `json:"ryzen"` Ryzen selection `json:"ryzen"`
} }
@ -102,6 +112,21 @@ func (m *model) prepare() {
m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)}) m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)})
} }
m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify}) m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify})
case 4:
flatpaks := make([]string, 0, len(m.catalog.Flatpaks))
for id := range m.catalog.Flatpaks {
flatpaks = append(flatpaks, id)
}
sort.Strings(flatpaks)
for _, id := range flatpaks {
option := m.catalog.Flatpaks[id]
m.rows = append(m.rows, row{"flatpak:" + id, "Flatpak: " + option.Label + " (" + option.Group + ")", contains(m.selection.Flatpaks, id)})
}
for _, id := range []string{"audio", "network", "bluetooth", "power"} {
if option, ok := m.catalog.Services[id]; ok {
m.rows = append(m.rows, row{"service:" + id, option.Label, contains(m.selection.Services, id)})
}
}
} }
} }
func (m *model) remember() { func (m *model) remember() {
@ -155,6 +180,19 @@ func (m *model) remember() {
} }
} }
m.selection.Configs = configNames m.selection.Configs = configNames
case 4:
m.selection.Flatpaks, m.selection.Services = []string{}, []string{}
for _, r := range m.rows {
if !r.checked {
continue
}
if strings.HasPrefix(r.id, "flatpak:") {
m.selection.Flatpaks = append(m.selection.Flatpaks, strings.TrimPrefix(r.id, "flatpak:"))
}
if strings.HasPrefix(r.id, "service:") {
m.selection.Services = append(m.selection.Services, strings.TrimPrefix(r.id, "service:"))
}
}
} }
} }
func (m model) makePreview() tea.Cmd { func (m model) makePreview() tea.Cmd {
@ -195,7 +233,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.loading { if m.loading {
return m, nil return m, nil
} }
if m.stage == 4 { if m.stage == 5 {
switch key { switch key {
case "up", "k": case "up", "k":
if m.scroll > 0 { if m.scroll > 0 {
@ -208,7 +246,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "esc": case "esc":
m.stage-- m.stage--
m.prepare() m.prepare()
case "enter", "s", "a", "i": case "enter", "s", "a", "i", "f", "v", "b", "r":
if m.errorText == "" { if m.errorText == "" {
m.done = true m.done = true
m.action = key m.action = key
@ -267,7 +305,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.remember() m.remember()
m.stage++ m.stage++
if m.stage == 4 { if m.stage == 5 {
m.loading = true m.loading = true
m.errorText = "" m.errorText = ""
return m, m.makePreview() return m, m.makePreview()
@ -287,13 +325,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) View() string { func (m model) View() string {
const purple = "\033[38;2;157;143;217m" const purple = "\033[38;2;157;143;217m"
const reset = "\033[0m" const reset = "\033[0m"
titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Final preview"} titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Flatpaks and optional services", "Final preview"}
text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n" text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n"
budget := m.height - 10 budget := m.height - 10
if budget < 5 { if budget < 5 {
budget = 5 budget = 5
} }
if m.stage == 4 { if m.stage == 5 {
if m.loading { if m.loading {
return text + " Preparing preview…\n" return text + " Preparing preview…\n"
} }
@ -305,7 +343,7 @@ func (m model) View() string {
for _, line := range lines[m.scroll:end] { for _, line := range lines[m.scroll:end] {
text += " " + line + "\n" text += " " + line + "\n"
} }
text += "\n ↑/↓ scroll · Esc back · Enter save plan · a restore configs · i install packages\n q cancel\n" text += "\n ↑/↓ scroll · Esc back · Enter save · r full restore · a configs · i packages\n f Flatpaks · v services · b bootloader · q cancel\n"
if m.errorText != "" { if m.errorText != "" {
text += " " + m.errorText + "\n" text += " " + m.errorText + "\n"
} }
@ -355,6 +393,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
m := model{repo: repo, catalog: c, selection: c.Stock, height: 28} m := model{repo: repo, catalog: c, selection: c.Stock, height: 28}
savedPath := filepath.Join(repo, "local", "selection.json")
if saved, readErr := os.ReadFile(savedPath); readErr == nil {
var previous selection
if json.Unmarshal(saved, &previous) == nil && (previous.Profile == "stock" || previous.Profile == "fiw-ryzen") {
m.selection = previous
m.stage = 1
}
}
m.prepare() m.prepare()
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run() result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil { if err != nil {
@ -377,27 +423,75 @@ func main() {
} }
fmt.Println(final.preview) fmt.Println(final.preview)
fmt.Println("\nSaved selection:", path) fmt.Println("\nSaved selection:", path)
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path} run := func(action string, root bool) error {
switch final.action { args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, action}
case "a":
args = append(args, "--apply-configs")
case "i":
args = append([]string{"python3"}, args...)
args = append(args, "--install-packages")
default:
fmt.Println("Review the setup notes in docs/setup.md before applying.")
return
}
program := "python3" program := "python3"
if final.action == "i" { if root {
program = "sudo" program = "sudo"
args = append([]string{"python3"}, args...)
} }
cmd := exec.Command(program, args...) cmd := exec.Command(program, args...)
cmd.Stdin = os.Stdin cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Stdout = os.Stdout return cmd.Run()
cmd.Stderr = os.Stderr }
if err := cmd.Run(); err != nil { services := func() error {
fmt.Fprintln(os.Stderr, err) system, user := false, false
for _, id := range final.selection.Services {
if c.Services[id].Scope == "system" {
system = true
}
if c.Services[id].Scope == "user" {
user = true
}
}
if system {
if err := run("--enable-services", true); err != nil {
return err
}
}
if user {
return run("--enable-services", false)
}
if !system {
fmt.Println("No services selected.")
}
return nil
}
var actionErr error
switch final.action {
case "a":
actionErr = run("--apply-configs", false)
case "i":
actionErr = run("--install-packages", true)
case "f":
actionErr = run("--install-flatpaks", false)
case "v":
actionErr = services()
case "b":
actionErr = run("--deploy-bootloader", true)
case "r":
actionErr = run("--check-configs", false)
if actionErr == nil && (len(final.selection.Groups) > 0 || len(final.selection.Extras) > 0 || final.selection.Bootloader != "keep" || len(final.selection.Flatpaks) > 0 || len(final.selection.Services) > 0) {
actionErr = run("--install-packages", true)
}
if actionErr == nil && len(final.selection.Flatpaks) > 0 {
actionErr = run("--install-flatpaks", false)
}
if actionErr == nil && len(final.selection.Services) > 0 {
actionErr = services()
}
if actionErr == nil && len(final.selection.Configs) > 0 {
actionErr = run("--apply-configs", false)
}
if actionErr == nil && final.selection.Bootloader != "keep" {
actionErr = run("--deploy-bootloader", true)
}
default:
fmt.Println("Saved. Choose an action in the TUI or use the commands in docs/setup.md.")
return
}
if actionErr != nil {
fmt.Fprintln(os.Stderr, actionErr)
os.Exit(1) os.Exit(1)
} }
} }

View file

@ -50,3 +50,19 @@ func TestConfigsSelectableWithoutPackageGroups(t *testing.T) {
t.Fatal("configs were hidden or deselected by package choices") t.Fatal("configs were hidden or deselected by package choices")
} }
} }
func TestSetupCheckboxesSurviveFinalPreview(t *testing.T) {
m := model{stage: 4, catalog: catalog{
Flatpaks: map[string]setupOption{"org.vinegarhq.Sober": {Label: "Sober", Group: "gaming"}},
Services: map[string]setupOption{"audio": {Label: "Audio", Scope: "user"}},
}, selection: selection{}}
m.prepare()
for i := range m.rows {
m.rows[i].checked = true
}
next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
result := next.(model)
if result.stage != 5 || command == nil || !contains(result.selection.Flatpaks, "org.vinegarhq.Sober") || !contains(result.selection.Services, "audio") {
t.Fatalf("setup selections lost before preview: %+v", result.selection)
}
}

View file

@ -21,3 +21,13 @@ theme metadata and colour-scheme notices. It still scans those files for
other private-data patterns. A changed fingerprint requires review before other private-data patterns. A changed fingerprint requires review before
updating tools/public-attribution.json. The fingerprinted upstream kernel updating tools/public-attribution.json. The fingerprinted upstream kernel
patch similarly retains public source/test data rather than being rewritten. patch similarly retains public source/test data rather than being rewritten.
## Distribution logos
The user-supplied Gentoo and Arch images are kept in `assets/images`. The
Gentoo image is used in the README; the Arch image is retained as supplied.
These distribution marks are separate from this project's original MIT code.
Upstream references: [Gentoo artwork](https://www.gentoo.org/inside-gentoo/artwork/)
and [name/logo guidelines](https://www.gentoo.org/inside-gentoo/foundation/name-logo-guidelines.html);
[Arch artwork](https://archlinux.org/art/) and
[trademark policy](https://terms.archlinux.org/docs/trademark-policy/).

View file

@ -1,20 +1,84 @@
# Optional bootloader choices # Optional bootloader deployment
`keep` is the default. `limine` and `grub` select separate package lists. `keep` is the default. Limine and GRUB have separate package choices; kernel
The draft does not install firmware entries or replace the active bootloader. selection remains independent. Selecting a loader does not itself deploy it.
Target-specific deployment still needs implementation and a reviewed target Use the final preview's **b** action or include it in **r** full restore.
ESP/root layout. Selecting a kernel does not select a bootloader.
GRUB integration must use the target system's installkernel configuration ## Preview and apply
and generate that system's grub.cfg. Limine integration must derive root and
ESP identifiers on the target and produce the selected custom/generic entries.
Fiw's current dual-boot paths and UUIDs do not belong in generic templates.
The retained `optional/fiw-limine-splice` refreshes the Gentoo block in an ```sh
existing Limine configuration and preserves other entries. It is specific ./install --selection local/selection.json --boot-plan
to the current marker/snippet design and is not installed by the default sudo ./install --selection local/selection.json --deploy-bootloader
rice preset or by the GRUB variant. # Specify a mounted ESP when automatic discovery does not find yours:
sudo ./install --selection local/selection.json --deploy-bootloader --esp /boot/efi
```
`optional/fiw-cachy-patch` supports the custom kernel, not a bootloader. It Discovery checks mounted `/efi`, `/boot/efi` and `/boot` candidates for the GPT
generates a CachyOS patch and updates the version guard/name. It requires ESP type and FAT filesystem. The plan shows the selected partition, root
root and matching upstream source releases; review before running. filesystem, kernel arguments, installed kernel/initramfs candidates and
proposed loader config or commands. UUIDs are derived on the target and kept
in its local state; they are not shipped in the repo.
Firmware registration has three choices:
| Choice | Effect |
|---|---|
| keep | Leave current firmware entries and BootOrder unchanged |
| add | Create an entry without adding it to BootOrder |
| default | Register the selected loader first in BootOrder |
The final deployment confirmation follows the target and firmware preview.
Existing matching firmware entries are reused when their partition and EFI
path match. Other entries are retained. No reboot is performed.
## Limine
The loader goes to `EFI/Fiw-Gentoo/BOOTX64.EFI`, with `limine.conf` beside it.
Versioned kernels and initramfs files are copied below that directory so
Limine can read them from the ESP. Custom Fiw/Cachy kernels appear first,
followed by generic kernels, including an installed binary fallback.
A marked block contains the generated Gentoo entries. Entries outside that
block are preserved. Locally edited managed entries receive a `.new` proposal
instead of being overwritten. ESP free space is checked before copying kernel
artifacts. The protocol and paths follow the upstream
[Limine configuration reference](https://github.com/limine-bootloader/limine/blob/v12.x/CONFIG.md).
## GRUB
`grub-install` uses `--target=x86_64-efi`, a dedicated `Fiw-Gentoo` EFI ID,
`/boot/fiw-gentoo` as its boot directory and `--no-nvram`. Firmware changes are
handled separately by the selected registration choice.
The generated menu lives in `/boot/fiw-gentoo/grub/grub.cfg`. A temporary output
is generated before replacing it. If the previous menu was edited locally,
the new menu becomes a `.new` proposal. Existing `/etc/default/grub` settings
are used by `grub-mkconfig`.
## Kernel updates and backups
Deployment installs a standalone refresh helper at
`/usr/local/libexec/fiw-dots-boot`, a systemd kernel-install hook and a
traditional Gentoo installkernel post-install hook. The active installkernel
configuration uses the compat layout and Dracut. Missing initramfs files are
generated when needed. Each refresh verifies the ESP is still mounted and
updates only the selected loader's managed menu.
The systemd remove hook excludes the removed kernel from its generated menu.
The legacy post-install hook refreshes after additions. Deployment saves
local boot state under `/etc/fiw-gentoo-dots/boot.json`; backup files and reports
live under `/var/lib/Fiw-Gentoo-Dots`. No existing disk partition is formatted.
Automatic deployment currently supports amd64 UEFI, GPT ESPs, plain ext4/Btrfs
roots and unsigned boot. Encrypted, UKI-only, BIOS and signing workflows can
keep their existing loader. Secure Boot being active stops unsigned deployment
before changes. Full boot execution on a fresh installation remains unverified.
## Retained helpers
`optional/fiw-limine-splice` is the earlier helper for Fiw's marker/snippet
layout. It is retained for manual use and is not installed by this deployment
or by the GRUB variant. The new refresh helper manages its own separate config.
`optional/fiw-cachy-patch` refreshes the custom kernel patch; it does not select
a bootloader. See the [helper list](scripts.md) before using legacy helpers.

29
docs/scripts.md Normal file
View file

@ -0,0 +1,29 @@
# Helpers and what they do
| Helper | Purpose | How it is used |
|---|---|---|
| `install` | Builds/opens the Bubble Tea chooser, or forwards CLI options to the Python backend | Main entry point |
| `lib/rice.py` | Validates selections, previews plans, restores configs and installs Portage packages | Called by the entry point/TUI |
| `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend |
| `lib/boot.py` | Detects the target ESP/root, previews/deploys Limine or GRUB, refreshes kernel menus | Boot action; copied as `fiw-dots-boot` on deployment |
| `fiw-shot` | Captures a region or monitor using Spectacle, copies it to the clipboard and offers Save/Edit through a notification | Installed with KDE shortcuts; `region` or `screen` argument |
| `tools/capture.py` | Recaptures the explicit preference allowlist without copying full personal profiles | Maintenance command; review the diff |
| `tools/shortcuts.py` | Regenerates the readable shortcut reference from captured repo files | Run after changing/capturing shortcuts |
| `tools/check-private.py` | Scans publishable files for personal paths, identifiers and credentials; keeps fingerprinted upstream attribution | Run before committing |
| `optional/fiw-cachy-patch` | Downloads matching vanilla/CachyOS kernel sources, generates a patch and adjusts the installed guard/name config | Explicit manual root operation; not part of ordinary restoration |
| `optional/fiw-limine-splice` | Replaces the old Gentoo snippet block in an existing Limine menu and orders its entries | Explicit manual use with an ESP argument; specific to the earlier snippet design |
| `optional/spotify/spotify-patch` | Runs the retained SpotX/Spicetify customization workflow for the Gentoo Spotify client | Explicit optional user operation; stock Spotify is the default |
| `optional/spotify/spicetify-snapshotfix` | Copies missing Marketplace fragments into the captured Spotify UI snapshot format | Called by the optional customization workflow |
The kernel refresh helper installed on a target runs with `--refresh`. Its
kernel hooks are created only when boot deployment is explicitly confirmed;
they are not enabled by the default `keep` choice.
The older Cachy patch helper predates the snapshot guard shipped by this
repo. It does not update the repo's `snapshot.json`, checksum or version mask;
review/update those together when preparing a new supported kernel snapshot.
It is retained as a reference, not run automatically.
For the optional Spotify scripts, see [the setup and risk note](spotify.md).
Unofficial Spotify modifications are optional and used at your own risk.
This project does not endorse bypassing paid features.

View file

@ -1,83 +1,131 @@
# Setup and current draft boundaries # Restore the setup
Start with an installed amd64 Gentoo system using systemd and a multilib Start with an installed amd64 Gentoo system using systemd and a multilib
Plasma profile. This repo does not partition disks or install the stage3. Plasma profile, with Git and Python 3.11+ available. Go 1.24+ builds the TUI.
Plasma Login Manager currently requires systemd. It is the default greeter This repo does not partition disks or install a stage3.
when installing the KDE section.
1. Run `./install`, make selections and review the final preview. ## Terminal interface
2. Save the selection. It lives in the ignored `local/selection.json`.
3. Export for inspection if desired:
`./install --selection local/selection.json --export local/stage`.
4. Install packages via the TUI or
`sudo ./install --selection local/selection.json --install-packages`.
Resolution happens in a temporary Portage config before live files change.
Missing binaries prompt compile/skip; final installation requires confirmation.
5. Apply user configs as the target user:
`./install --selection local/selection.json --apply-configs`.
Log out of Plasma and use a TTY for KDE configs; running KDE can overwrite
edited config files. Portable app configs can be restored separately.
For updates, review the Git changes, then use the saved selection with Run `./install`. Choose a preset, package sections, individual configs,
`--apply-configs --update`. There is no automatic git pull or upstream upgrade. kernel/bootloader options, Flatpaks and optional services. Previous selections
Backups and reports live under `~/.local/state/Fiw-Gentoo-Dots`. Package reports are loaded from the ignored `local/selection.json`; press Esc to return to
and system backups live under `/var/lib/Fiw-Gentoo-Dots`. preset selection when you want to change profiles.
## Explicit setup steps At the final preview:
Root package installation enables `plasmalogin.service` for selected KDE | Key | Action |
installs without restarting the running greeter. After the desired packages |---|---|
are installed, enable the services you use: | Enter / s | Save the selection without applying it |
| r | Full restore: packages, Flatpaks, services, configs, selected bootloader |
| i | Install Portage packages |
| f | Install selected user Flatpaks |
| v | Enable selected services |
| a | Restore selected user configs |
| b | Preview and deploy the selected bootloader |
Full restore checks the KDE session before making package changes. Log out of
Plasma and run from a TTY when KDE configs are selected. Each operation also
has a command for resuming a saved selection:
```sh ```sh
sudo systemctl enable NetworkManager bluetooth power-profiles-daemon ./install --selection local/selection.json --plan
systemctl --user enable pipewire.socket pipewire-pulse.socket wireplumber.service sudo ./install --selection local/selection.json --install-packages
./install --selection local/selection.json --install-flatpaks
sudo ./install --selection local/selection.json --enable-services
./install --selection local/selection.json --enable-services
./install --selection local/selection.json --apply-configs
sudo ./install --selection local/selection.json --deploy-bootloader
``` ```
Docker stays disabled until needed. The installer does not reboot or power off. The root service command handles system services; the regular-user command
handles PipeWire units. User Flatpaks and user configs are never applied as root.
Flatpak apps are listed separately. Add Flathub if absent, then install only ## Packages and repositories
the selected categories' apps explicitly, for example:
Existing GURU and steam-overlay checkouts are reused. Missing overlays needed
by the selected package sections are cloned into temporary storage before
Portage resolves the plan. Their identity is checked, and they are adopted
under `/var/db/repos` only after confirmation. Existing repository checkouts
are not automatically synced or replaced.
Additional source builds prompt **compile or skip**, with compilation
recommended. Skipping a dependency also skips requested packages needing it.
A package report records requested, skipped and missing packages. There is a
final package confirmation before live Portage configuration changes.
Read-only resolution and an export are available separately:
```sh ```sh
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo ./install --selection local/selection.json --check-packages
flatpak install --user flathub org.localsend.localsend_app ./install --selection local/selection.json --export local/stage
flatpak install --user flathub org.vinegarhq.Sober
``` ```
The independently selectable `fonts` config includes the four used styles of The check may download a missing overlay into its temporary root; it does not
JetBrainsMono Nerd Font Mono from the official Nerd Fonts v3.5.1 release, register that checkout or change the live Portage configuration. The export
with its OFL licence. It works with KDE and portable apps alike. The entire contains proposed files and a plan, not a ready-to-merge repository checkout.
personal font collection is not included. Neovim uses its built-in theme as
a fallback when Catppuccin is not installed. Vesktop's selected Midnight Plasma Login Manager is enabled for selected KDE installs. The installer does
theme CSS is captured with a fixed upstream snapshot and its MIT licence. not restart the running greeter, reboot or power off.
## Flatpaks and services
LocalSend (`fiw-apps`) and Sober (`gaming`) have individual Flatpak checkboxes.
Both start unchecked. Selecting them ensures Flatpak is in the package plan;
the separate Flatpak action adds Flathub for the user if absent and installs
only missing selected apps. Existing user installations are kept. Failures
and skips are reported.
Audio, NetworkManager, Bluetooth and power profiles have independent service
checkboxes, also initially unchecked. Their required packages are added when
needed. Enabling a service schedules it for the next boot or user login; the
workflow does not start, stop or restart services. Missing units are reported.
Docker remains a separate choice outside this service preset.
## Configs and updates
Every config is optional and independent of package installation groups.
The separate fonts config includes four JetBrainsMono Nerd Font Mono styles.
Vesktop's Midnight CSS is included with a fixed upstream snapshot. See the
[KDE styling reference](kde-style.md) and [shortcut list](shortcuts.md).
Review Git changes before pulling updates, then restore your saved selection:
```sh
./install --selection local/selection.json --apply-configs --update
```
Locally edited configs remain intact; proposed changes are written to `.new`
files. Replacements receive backups. Root Portage file conflicts ask before
replacement and receive backups too. Reports and user backups live under
`~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under
`/var/lib/Fiw-Gentoo-Dots`.
## Fiw tools ## Fiw tools
The `fiw-tools` section includes FiwNode's x86-64 binary release and the The `fiw-tools` section includes FiwNode's binary release and the released
released Apdatifier Gentoo widget, alongside OpenDeck and Music Presence. Apdatifier Gentoo widget, alongside OpenDeck and Music Presence. Apdatifier
Apdatifier installs QML/scripts without compilation; any dependencies needing installs QML/scripts without compilation; source-only dependencies still
source builds still go through the normal compile/skip prompt. require the normal compile/skip choice.
FiwNode uses its default configuration. The installer does not copy sound FiwNode uses its default config. Sound libraries and audio device selections
libraries, microphone/output selections or personal app state. Older copies stay local. Copies in `~/.local/bin` can shadow `/usr/bin`; manage old copies
in `~/.local/bin` can shadow the Portage-installed binaries in `/usr/bin`; manually when switching to the package. Its daemon starts on demand.
remove those manually when ready to switch. The daemon starts on demand.
Apdatifier is available through Plasma's **Add Widgets** menu. Installing it Add Apdatifier through Plasma's **Add Widgets** menu. Installation does not
does not alter panels. An existing user-installed widget can override the alter panels. An existing user-installed widget can override the system copy;
system copy; use `kpackagetool6` to manage it. Its preferences remain local. manage it with `kpackagetool6`. Its preferences stay local.
## Remaining work before declaring a fresh install verified ## Current scope
- Run both presets on a fresh Gentoo test installation. Root package execution Automatic boot deployment supports amd64 UEFI, a mounted GPT ESP, plain ext4
has not been exercised on the working desktop. or Btrfs roots, and unsigned boot. See [boot setup](boot.md) for previews,
- Implement target-specific Limine/GRUB deployment after reviewing the target firmware choices and kernel update integration. Other layouts can keep their
ESP and boot layout. Current variants select packages and provide setup notes. existing loader.
- Refresh the optional TideWM live ebuild branch/dependency recipe against the
upstream development version before building it on a new machine.
- Repository sync is an explicit prerequisite when GURU or steam-overlay is
absent; the draft aborts cleanly on resolution failure rather than guessing.
Do not publish the private original fiw-gentoo repository. It is the working The optional TideWM live recipe still needs a refresh against its upstream
archive and has different history and host-specific files. development branch. End-to-end package and boot execution on a fresh Gentoo
installation remains unverified; development checks used temporary homes,
staged Portage configuration and mocked system commands.
Do not publish the private original fiw-gentoo archive. It has different
history and host-specific files.

431
lib/boot.py Normal file
View file

@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""Preview and deploy a separate amd64 UEFI Limine or GRUB installation."""
import argparse
import hashlib
import json
import os
import platform
import re
import shlex
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
STATE = Path('/etc/fiw-gentoo-dots/boot.json')
MANAGED = Path('/var/lib/Fiw-Gentoo-Dots')
BEGIN = '# >>> Fiw-Gentoo-Dots kernels'
END = '# <<< Fiw-Gentoo-Dots kernels'
EFI_DIRECTORY = 'EFI/Fiw-Gentoo'
GRUB_DIRECTORY = Path('/boot/fiw-gentoo')
def mount_info(path, exact=False):
result = subprocess.run(['findmnt', '--json', '--mountpoint' if exact else '--target',
str(path), '--output', 'TARGET,SOURCE,FSTYPE,UUID,OPTIONS'],
text=True, capture_output=True)
if result.returncode:
raise RuntimeError('Not a mounted filesystem: ' + str(path))
data = json.loads(result.stdout)['filesystems'][0]
return data
def discover_esp(path=None):
candidates = [Path(path)] if path else [Path('/efi'), Path('/boot/efi'), Path('/boot')]
for candidate in candidates:
try:
info = mount_info(candidate, exact=True)
except RuntimeError:
continue
if info['fstype'] not in ('vfat', 'fat', 'fat32'):
continue
source = re.sub(r'\[.*\]$', '', info['source'])
device = Path(source).resolve()
partition = Path('/sys/class/block') / device.name / 'partition'
if not partition.is_file():
continue
# Require the GPT ESP type, not an arbitrary mounted FAT data partition.
result = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'PARTTYPE', str(device)],
text=True, capture_output=True)
esp_type = 'c12a7328f81f11d2ba4b00a0c93ec93b' # GPT ESP type, without separators
if result.returncode or result.stdout.strip().lower().replace('-', '') != esp_type:
continue
parent = (Path('/sys/class/block') / device.name).resolve().parent.name
identifier = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'PARTUUID', str(device)], text=True, capture_output=True)
return {'partuuid': identifier.stdout.strip().lower(), 'path': str(candidate.resolve()), 'device': str(device),
'disk': '/dev/' + parent, 'partition': int(partition.read_text())}
raise RuntimeError('Select a mounted GPT EFI System Partition with --esp. No ESP was found.')
def kernel_images(boot=Path('/boot'), exclude=None):
found = {}
for pattern, prefix in [('vmlinuz-*', 'vmlinuz-'), ('kernel-*', 'kernel-')]:
for path in boot.glob(pattern):
version = path.name[len(prefix):]
if path.is_file() and re.fullmatch(r'[A-Za-z0-9_.+-]+', version) and version != exclude:
initrds = [boot / ('initramfs-' + version + '.img'), boot / ('initrd-' + version),
boot / ('initrd.img-' + version)]
found.setdefault(version, {'version': version, 'kernel': str(path),
'initrd': next((str(p) for p in initrds if p.is_file()), None)})
# systemd BLS installations store a linux/initrd pair below their entry ID.
for path in boot.glob('*/*/linux'):
version = path.parent.name
if path.is_file() and re.fullmatch(r'[A-Za-z0-9_.+-]+', version) and version != exclude:
initrd = path.parent / 'initrd'
found.setdefault(version, {'version': version, 'kernel': str(path),
'initrd': str(initrd) if initrd.is_file() else None})
for path in Path('/usr/lib/modules').glob('*/vmlinuz'):
version = path.parent.name
if boot == Path('/boot') and path.is_file() and version != exclude and re.fullmatch(r'[A-Za-z0-9_.+-]+', version):
initrd = boot / ('initramfs-' + version + '.img')
found.setdefault(version, {'version': version, 'kernel': str(path),
'initrd': str(initrd) if initrd.is_file() else None})
versions = subprocess.run(['sort', '-V'], input='\n'.join(found), text=True,
capture_output=True, check=True).stdout.splitlines()[::-1]
versions.sort(key=lambda v: not any(word in v.lower() for word in ('fiw', 'cachy')))
return [found[v] for v in versions]
def kernel_cmdline(root):
config = Path('/etc/kernel/cmdline')
raw = config.read_text().strip() if config.is_file() else Path('/proc/cmdline').read_text().strip()
tokens = shlex.split(raw)
tokens = [token for token in tokens if not token.startswith(('BOOT_IMAGE=', 'initrd=', 'root='))]
if not root.get('uuid'):
raise RuntimeError('Cannot identify the target root filesystem UUID.')
tokens.insert(0, 'root=UUID=' + root['uuid'])
if root['fstype'] == 'btrfs' and not any(t.startswith('rootflags=') for t in tokens):
flags = [flag for flag in root.get('options', '').split(',') if flag.startswith('subvol=')]
if flags:
tokens.append('rootflags=' + flags[0])
text = ' '.join(tokens)
if any(char in text for char in ('\n', '\r', '\0')):
raise RuntimeError('Invalid kernel command line.')
return text
def secure_boot():
files = list(Path('/sys/firmware/efi/efivars').glob('SecureBoot-*'))
return bool(files and len(files[0].read_bytes()) > 4 and files[0].read_bytes()[4] == 1)
def limine_loader():
for path in [Path('/usr/share/limine/BOOTX64.EFI'), Path('/usr/share/limine/limine-uefi-cd.bin')]:
# A CD image is not an EFI executable; only actual PE EFI files qualify.
if path.is_file() and path.open('rb').read(2) == b'MZ':
return path
for path in Path('/usr/share/limine').rglob('BOOTX64.EFI'):
if path.is_file():
return path
raise RuntimeError('Limine BOOTX64.EFI is missing; install sys-boot/limine with uefi-x86-64.')
def render_limine(images, cmdline):
lines = [BEGIN]
for image in images:
version = image['version']
label = 'custom' if any(word in version.lower() for word in ('fiw', 'cachy')) else 'generic'
lines += [f'/Gentoo {version} ({label})', ' protocol: linux',
f' path: boot():/{EFI_DIRECTORY}/kernels/{version}/vmlinuz',
f' module_path: boot():/{EFI_DIRECTORY}/kernels/{version}/initramfs.img',
' cmdline: ' + cmdline, '']
return '\n'.join(lines + [END]) + '\n'
def managed_block(text):
if text.count(BEGIN) != text.count(END) or text.count(BEGIN) > 1:
raise RuntimeError('Malformed managed Limine block; preserving the config.')
if BEGIN not in text:
return None
start = text.index(BEGIN)
end = text.index(END, start) + len(END)
return text[start:end] + '\n'
def merge_limine(text, block):
old = managed_block(text)
if old is None:
return text.rstrip() + '\n\n' + block if text.strip() else 'timeout: 5\n\n' + block
return text.replace(old.rstrip('\n'), block.rstrip('\n'), 1)
def file_hash(path):
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
def block_hash(block):
return hashlib.sha256(block.encode()).hexdigest() if block else None
def install_config(old):
values = {'layout': 'compat', 'initrd_generator': 'dracut', 'uki_generator': 'none'}
lines, seen = [], set()
for line in old.splitlines():
key = line.split('=', 1)[0].strip()
if key in values:
if key not in seen:
lines.append(key + '=' + values[key])
seen.add(key)
else:
lines.append(line)
lines += [key + '=' + value for key, value in values.items() if key not in seen]
return '\n'.join(lines) + '\n'
def plan(mode, esp=None, exclude=None):
if mode == 'keep':
return {'mode': 'keep', 'changes': []}
if mode not in ('limine', 'grub'):
raise ValueError('Unknown bootloader')
if platform.machine() not in ('x86_64', 'amd64') or not Path('/sys/firmware/efi').is_dir():
raise RuntimeError('Automatic boot deployment supports amd64 UEFI installations.')
root = mount_info('/')
if root['fstype'] not in ('ext4', 'btrfs') or root['source'].startswith('/dev/mapper/'):
raise RuntimeError('Automatic boot deployment currently supports plain ext4/Btrfs roots. Keep the existing loader for encrypted or other layouts.')
source = re.sub(r'\[.*\]$', '', root['source'])
device_type = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'TYPE', source], text=True, capture_output=True)
if device_type.returncode or device_type.stdout.strip() not in ('part', 'disk'):
raise RuntimeError('Automatic boot deployment needs a plain disk/partition root; keep the existing loader for this storage layout.')
target = discover_esp(esp)
images = kernel_images(exclude=exclude)
if not images:
raise RuntimeError('No installed kernel/initramfs candidates found under /boot. Install a distribution kernel first.')
text = kernel_cmdline(root)
result = {'mode': mode, 'esp': target, 'root_filesystem': root['fstype'],
'cmdline': text, 'images': images, 'secure_boot': secure_boot(),
'firmware': 'preserve current entries/order unless explicitly selected',
'kernel_refresh_hook': '/etc/kernel/install.d/96-fiw-dots-boot.install'}
if mode == 'limine':
result['loader'] = str(limine_loader())
result['config'] = str(Path(target['path']) / EFI_DIRECTORY / 'limine.conf')
result['proposed_entries'] = render_limine(images, text)
else:
result['config'] = str(GRUB_DIRECTORY / 'grub/grub.cfg')
result['commands'] = [['grub-install', '--target=x86_64-efi', '--efi-directory=' + target['path'],
'--boot-directory=' + str(GRUB_DIRECTORY), '--bootloader-id=Fiw-Gentoo', '--no-nvram'],
['grub-mkconfig', '-o', result['config']]]
prior = Path('/etc/kernel/install.conf').read_text() if Path('/etc/kernel/install.conf').is_file() else ''
result['installkernel_config'] = install_config(prior)
for image in images:
if not image['initrd']:
image['generate_initrd'] = '/boot/initramfs-' + image['version'] + '.img'
return result
def backup(path, root):
if path.exists() or path.is_symlink():
target = root / str(path.absolute()).lstrip('/')
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target, follow_symlinks=False)
def write(path, content, backups, mode=0o644):
backup(path, backups)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name('.' + path.name + '.fiw-dots-tmp')
tmp.write_text(content)
tmp.chmod(mode)
tmp.replace(path)
def refresh(saved, backups, exclude=None):
# Recheck the mount every time; never write into an unmounted ESP directory.
target = discover_esp(Path(saved['esp']))
images = kernel_images(exclude=exclude)
if not images:
raise RuntimeError('No remaining kernels; preserving the boot menu.')
for image in images:
if not image['initrd']:
if not shutil.which('dracut'):
raise RuntimeError('Missing initramfs for ' + image['version'] + '; install sys-kernel/dracut.')
path = Path('/boot/initramfs-' + image['version'] + '.img')
subprocess.run(['dracut', '--force', str(path), image['version']], check=True)
image['initrd'] = str(path)
if saved['mode'] == 'grub':
config = GRUB_DIRECTORY / 'grub/grub.cfg'
old_hash = file_hash(config)
output = config
if old_hash and old_hash != saved.get('config_hash'):
output = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
else:
backup(config, backups)
output.parent.mkdir(parents=True, exist_ok=True)
for image in images:
source = Path(image['kernel'])
if source.parent != Path('/boot'):
dest = Path('/boot') / ('kernel-' + image['version'])
if file_hash(dest) != file_hash(source):
backup(dest, backups)
shutil.copy2(source, dest)
source = Path(image['initrd'])
dest = Path('/boot') / ('initramfs-' + image['version'] + '.img')
if source != dest and not dest.exists():
shutil.copy2(source, dest)
scratch = output.with_name('.' + output.name + '.tmp')
try:
subprocess.run(['grub-mkconfig', '-o', str(scratch)], check=True)
scratch.replace(output)
finally:
if scratch.exists():
scratch.unlink()
if output != config:
print('Preserved edited GRUB config; review ' + str(output))
else:
saved['config_hash'] = file_hash(config)
return saved
folder = Path(target['path']) / EFI_DIRECTORY
required = sum(Path(source).stat().st_size for image in images
for source, name in [(image['kernel'], 'vmlinuz'), (image['initrd'], 'initramfs.img')]
if file_hash(folder / 'kernels' / image['version'] / name) != file_hash(Path(source)))
if shutil.disk_usage(target['path']).free < required + 16 * 1024 * 1024:
raise RuntimeError('Not enough free space on the ESP for the selected kernels and initramfs files.')
for image in images:
directory = folder / 'kernels' / image['version']
directory.mkdir(parents=True, exist_ok=True)
for source, name in [(image['kernel'], 'vmlinuz'), (image['initrd'], 'initramfs.img')]:
dest = directory / name
if file_hash(dest) != file_hash(Path(source)):
backup(dest, backups)
scratch = dest.with_name('.' + name + '.tmp')
shutil.copy2(source, scratch)
scratch.replace(dest)
config = folder / 'limine.conf'
old = config.read_text() if config.is_file() else ''
prior = managed_block(old)
block = render_limine(images, saved['cmdline'])
output = merge_limine(old, block)
if prior and block_hash(prior) != saved.get('block_hash'):
proposal = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
write(proposal, output, backups)
print('Preserved edited Limine entries; review ' + str(proposal))
elif output != old:
write(config, output, backups)
saved['block_hash'] = block_hash(block)
return saved
def confirm(prompt, choices, default):
if not sys.stdin.isatty():
raise RuntimeError('Interactive confirmation required: ' + prompt)
while True:
value = input(prompt + ' [' + '/'.join(choices) + '] (default ' + default + '): ').strip().lower() or default
if value in choices:
return value
def firmware_commands(target, mode, loader_path, choice, listing):
if choice == 'keep':
return []
label = 'Fiw-Gentoo-' + mode
matches = []
for line in listing.splitlines():
match = re.match(r'^Boot([0-9A-Fa-f]{4})\*?\s+' + re.escape(label) + r'\s', line)
if match and target.get('partuuid') and target['partuuid'] in line.lower() and loader_path.lower().replace('/', '\\') in line.lower():
matches.append(match.group(1))
if matches:
if choice == 'add':
return []
order = re.search(r'^BootOrder:\s*(.*)$', listing, re.M)
sequence = [matches[0]] + [item for item in (order.group(1).split(',') if order else [])
if item.upper() != matches[0].upper()]
return [['efibootmgr', '--bootorder', ','.join(sequence)]]
return [['efibootmgr', '--create' if choice == 'default' else '--create-only',
'--disk', target['disk'], '--part', str(target['partition']),
'--label', label, '--loader', loader_path.replace('/', '\\')]]
def deploy(mode, esp=None, ask=confirm):
if mode == 'keep':
print('Keeping the current bootloader.')
return
if os.geteuid() != 0:
raise RuntimeError('Boot deployment needs root. Run sudo ./install --deploy-bootloader --selection <file>.')
proposed = plan(mode, esp)
if proposed['secure_boot']:
raise RuntimeError('Secure Boot is active. This unsigned deployment requires a separately configured signing setup.')
for command in (['grub-install', 'grub-mkconfig'] if mode == 'grub' else []):
if not shutil.which(command):
raise RuntimeError('Missing command: ' + command)
if any(not image['initrd'] for image in proposed['images']) and not shutil.which('dracut'):
raise RuntimeError('Missing initramfs generator: dracut')
print(json.dumps(proposed, indent=2))
firmware = ask('UEFI entry: preserve current order, add a menu entry, or make this loader default?',
['keep', 'add', 'default'], 'keep')
if firmware != 'keep' and not shutil.which('efibootmgr'):
raise RuntimeError('Install sys-boot/efibootmgr before registering a firmware entry.')
loader_path = '/EFI/Fiw-Gentoo/' + ('BOOTX64.EFI' if mode == 'limine' else 'grubx64.efi')
listing = subprocess.run(['efibootmgr', '--verbose'], text=True, capture_output=True, check=True).stdout if firmware != 'keep' else ''
commands = firmware_commands(proposed['esp'], mode, loader_path, firmware, listing)
if commands:
print('Proposed firmware commands: ' + json.dumps(commands))
if ask('Deploy the previewed bootloader and kernel refresh hook?', ['deploy', 'cancel'], 'cancel') != 'deploy':
return
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
backups = MANAGED / 'backups' / ('boot-' + stamp)
folder = Path(proposed['esp']['path']) / EFI_DIRECTORY
if folder.exists():
shutil.copytree(folder, backups / 'efi-directory', symlinks=True)
if GRUB_DIRECTORY.exists() and mode == 'grub':
shutil.copytree(GRUB_DIRECTORY, backups / 'grub-directory', symlinks=True)
prior = json.loads(STATE.read_text()) if STATE.is_file() else {}
saved = {'mode': mode, 'esp': proposed['esp']['path'], 'cmdline': proposed['cmdline']}
if prior.get('mode') == mode and prior.get('esp') == saved['esp']:
saved.update({key: prior[key] for key in ('config_hash', 'block_hash') if key in prior})
folder.mkdir(parents=True, exist_ok=True)
if mode == 'limine':
loader = folder / 'BOOTX64.EFI'
backup(loader, backups)
shutil.copy2(proposed['loader'], loader)
loader_path = '/EFI/Fiw-Gentoo/BOOTX64.EFI'
else:
subprocess.run(proposed['commands'][0], check=True)
loader_path = '/EFI/Fiw-Gentoo/grubx64.efi'
saved = refresh(saved, backups)
# Native installkernel provides versioned /boot kernel/initrd files; our
# final hook refreshes only the chosen loader's own configuration.
write(Path('/etc/kernel/install.conf'), proposed['installkernel_config'], backups)
script = Path('/usr/local/libexec/fiw-dots-boot')
backup(script, backups)
script.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__), script)
script.chmod(0o755)
hook = '#!/bin/sh\ncase "$1" in\n add) exec /usr/local/libexec/fiw-dots-boot --refresh ;;\n remove) exec /usr/local/libexec/fiw-dots-boot --refresh --exclude-version "$2" ;;\nesac\n'
write(Path('/etc/kernel/install.d/96-fiw-dots-boot.install'), hook, backups, 0o755)
# Traditional Gentoo installkernel uses postinst.d rather than install.d.
legacy = '#!/bin/sh\nexec /usr/local/libexec/fiw-dots-boot --refresh\n'
write(Path('/etc/kernel/postinst.d/96-fiw-dots-boot'), legacy, backups, 0o755)
write(STATE, json.dumps(saved, indent=2) + '\n', backups, 0o600)
for command in commands:
subprocess.run(command, check=True)
report = MANAGED / ('boot-' + stamp + '.json')
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware,
'backups': str(backups)}, indent=2) + '\n')
print('Boot deployment finished. Report: ' + str(report))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--refresh', action='store_true')
parser.add_argument('--exclude-version')
args = parser.parse_args()
if not args.refresh:
parser.error('Use ./install --boot-plan or --deploy-bootloader for initial setup.')
if os.geteuid() != 0:
raise RuntimeError('Kernel refresh requires root.')
if not STATE.is_file():
return
saved = json.loads(STATE.read_text())
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
backups = MANAGED / 'backups' / ('boot-refresh-' + stamp)
updated = refresh(saved, backups, args.exclude_version)
write(STATE, json.dumps(updated, indent=2) + '\n', backups, 0o600)
if __name__ == '__main__':
try:
main()
except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as error:
print('Boot refresh: ' + str(error), file=sys.stderr)
sys.exit(1)

View file

@ -14,7 +14,11 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[1] REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / 'tools')) sys.path.insert(0, str(REPO / 'tools'))
sys.path.insert(0, str(REPO / 'lib'))
from capture import sections from capture import sections
from setup import (FLATPAKS, SERVICES, required_repositories,
existing_repository, repository_config, stage_repositories,
publish_repositories, install_flatpaks, enable_services)
GROUPS = ['system', 'kde', 'fiw-apps', 'cli', 'dev', 'gaming', 'fiw-tools', 'tidewm'] GROUPS = ['system', 'kde', 'fiw-apps', 'cli', 'dev', 'gaming', 'fiw-tools', 'tidewm']
EXTRAS = {'filelight': 'Filelight', 'btrfs': 'Btrfs tools', 'nvidia': 'NVIDIA drivers'} EXTRAS = {'filelight': 'Filelight', 'btrfs': 'Btrfs tools', 'nvidia': 'NVIDIA drivers'}
@ -29,8 +33,14 @@ def load_selection(path=None, profile='stock'):
selection = json.loads((Path(path) if path else REPO / 'presets' / (profile + '.json')).read_text()) selection = json.loads((Path(path) if path else REPO / 'presets' / (profile + '.json')).read_text())
if selection.get('profile') not in ('stock', 'fiw-ryzen'): if selection.get('profile') not in ('stock', 'fiw-ryzen'):
raise ValueError('Unknown preset') raise ValueError('Unknown preset')
for field, allowed in [('groups', GROUPS), ('configs', CATALOG), ('extras', EXTRAS)]: for field in ('flatpaks', 'services'):
if any(x not in allowed for x in selection.get(field, [])): if selection.get(field) is None:
selection[field] = []
for field, allowed in [('groups', GROUPS), ('configs', CATALOG), ('extras', EXTRAS),
('flatpaks', FLATPAKS), ('services', SERVICES)]:
if not isinstance(selection.get(field, []), list):
raise ValueError('Selection must be a list: ' + field)
if any(not isinstance(x, str) or x not in allowed for x in selection.get(field, [])):
raise ValueError('Unknown selection in ' + field) raise ValueError('Unknown selection in ' + field)
if selection.get('kernel') not in ('binary', 'custom'): if selection.get('kernel') not in ('binary', 'custom'):
raise ValueError('Unknown kernel choice') raise ValueError('Unknown kernel choice')
@ -54,6 +64,17 @@ def package_map(selection):
result[extra] = atoms(REPO / 'packages/optional' / (extra + '.list')) result[extra] = atoms(REPO / 'packages/optional' / (extra + '.list'))
if selection['bootloader'] != 'keep': if selection['bootloader'] != 'keep':
result['boot-' + selection['bootloader']] = atoms(REPO / 'packages/optional' / (selection['bootloader'] + '.list')) result['boot-' + selection['bootloader']] = atoms(REPO / 'packages/optional' / (selection['bootloader'] + '.list'))
support = []
if selection.get('flatpaks'):
support.append('sys-apps/flatpak')
for key in selection.get('services', []):
support += SERVICES[key]['packages']
if selection['bootloader'] != 'keep':
support += ['sys-kernel/installkernel', 'sys-kernel/dracut', 'sys-boot/efibootmgr']
selected = {atom.split('::')[0] for packages in result.values() for atom in packages}
support = [atom for atom in dict.fromkeys(support) if atom not in selected]
if support:
result['setup'] = support
return result return result
@ -153,11 +174,16 @@ def active_plasma(home):
return result.returncode == 0 return result.returncode == 0
def check_configs(selection, home):
entries = list(config_entries(selection))
if any(CATALOG[name].get('kde') for name, _, _, _ in entries) and active_plasma(home.resolve()):
raise RuntimeError('Log out of Plasma and apply from a TTY to prevent KDE overwriting restored settings. Portable app configs can be applied separately.')
def apply_configs(selection, home, update=False, conflict='ask'): def apply_configs(selection, home, update=False, conflict='ask'):
home = home.resolve() home = home.resolve()
entries = list(config_entries(selection)) entries = list(config_entries(selection))
if any(CATALOG[name].get('kde') for name, _, _, _ in entries) and active_plasma(home): check_configs(selection, home)
raise RuntimeError('Log out of Plasma and apply from a TTY to prevent KDE overwriting restored settings. Portable app configs can be applied separately.')
state_dir = home / '.local/state/Fiw-Gentoo-Dots' state_dir = home / '.local/state/Fiw-Gentoo-Dots'
index_path = state_dir / 'managed.json' index_path = state_dir / 'managed.json'
index = json.loads(index_path.read_text()) if index_path.is_file() else {} index = json.loads(index_path.read_text()) if index_path.is_file() else {}
@ -255,8 +281,9 @@ def root_files(selection, repo_location=None):
result['etc/portage/fiw-dots.conf'] += '\n# Persist the selected binary/source policy for later emerge operations.\n' result['etc/portage/fiw-dots.conf'] += '\n# Persist the selected binary/source policy for later emerge operations.\n'
result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n' result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n'
result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n' result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n'
for name, uri in [('guru', 'https://github.com/gentoo-mirror/guru.git'), ('steam-overlay', 'https://github.com/anyc/steam-overlay.git')]: for name in required_repositories(selection):
result['etc/portage/repos.conf/fiw-dots-' + name + '.conf'] = '[' + name + ']\nlocation = /var/db/repos/' + name + '\nsync-type = git\nsync-uri = ' + uri + '\nsync-depth = 1\npriority = 10\n' if not existing_repository(name):
result['etc/portage/repos.conf/fiw-dots-' + name + '.conf'] = repository_config(name, '/var/db/repos/' + name)
arch = 'x86-64-v3' if selection['profile'] == 'fiw-ryzen' else 'x86-64' arch = 'x86-64-v3' if selection['profile'] == 'fiw-ryzen' else 'x86-64'
result['etc/portage/binrepos.conf/fiw-dots.conf'] = '[gentoo]\npriority = 1\nsync-uri = https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/' + arch + '/\nlocation = /var/cache/binhost/gentoo\nverify-signature = true\n' result['etc/portage/binrepos.conf/fiw-dots.conf'] = '[gentoo]\npriority = 1\nsync-uri = https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/' + arch + '/\nlocation = /var/cache/binhost/gentoo\nverify-signature = true\n'
if selection['kernel'] == 'custom' and 'system' in selection['groups']: if selection['kernel'] == 'custom' and 'system' in selection['groups']:
@ -265,6 +292,14 @@ def root_files(selection, repo_location=None):
result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n' result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n'
result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text() result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text()
result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel snapshot; update snapshot and patch explicitly.\n>sys-kernel/gentoo-kernel-7.2.8\n' result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel snapshot; update snapshot and patch explicitly.\n>sys-kernel/gentoo-kernel-7.2.8\n'
if selection['bootloader'] != 'keep':
result['etc/portage/package.use/fiw-dots-boot'] = ('sys-kernel/installkernel systemd dracut -grub -uki -ukify -ugrd -efistub -refind -systemd-boot\n')
if selection['bootloader'] == 'grub':
result['etc/portage/env/fiw-dots-grub.conf'] = 'GRUB_PLATFORMS="efi-64"\n'
result['etc/portage/package.env/fiw-dots-grub'] = 'sys-boot/grub fiw-dots-grub.conf\n'
else:
result['etc/portage/package.accept_keywords/fiw-dots-limine'] = 'sys-boot/limine ~amd64\n'
result['etc/portage/package.use/fiw-dots-limine'] = 'sys-boot/limine uefi-x86-64 -bios -bios-cd -bios-pxe -uefi-cd -uefi-ia32 -uefi-aarch64 -uefi-riscv64 -uefi-loongarch64\n'
if 'tidewm' in selection['groups']: if 'tidewm' in selection['groups']:
result['etc/portage/package.accept_keywords/fiw-dots-tidewm'] = 'gui-wm/tidewm::fiw-dots **\n' result['etc/portage/package.accept_keywords/fiw-dots-tidewm'] = 'gui-wm/tidewm::fiw-dots **\n'
return result return result
@ -275,7 +310,11 @@ def preview(selection):
lines = ["Fiw-Gentoo-Dots — " + selection['name'], ''] lines = ["Fiw-Gentoo-Dots — " + selection['name'], '']
for group, values in packages.items(): for group, values in packages.items():
lines += [group + ' (' + str(len(values)) + ')', ' ' + ', '.join(values)] lines += [group + ' (' + str(len(values)) + ')', ' ' + ', '.join(values)]
lines += ['', 'Compile deliberately: ' + ', '.join(source_policy(selection)), lines += ['', 'Repositories: ' + (', '.join(required_repositories(selection)) or 'Gentoo + fiw-dots only'),
'Missing selected overlays are staged before resolution and adopted after confirmation.',
'Flatpaks (user): ' + (', '.join(selection.get('flatpaks', [])) or 'none'),
'Optional services: ' + (', '.join(selection.get('services', [])) or 'none'),
'Compile deliberately: ' + ', '.join(source_policy(selection)),
'Other packages: prefer binaries; ask compile/skip for additional source builds.', 'Other packages: prefer binaries; ask compile/skip for additional source builds.',
'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''), 'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''),
'Bootloader: ' + selection['bootloader'], '', 'Selected configs:'] 'Bootloader: ' + selection['bootloader'], '', 'Selected configs:']
@ -286,7 +325,7 @@ def preview(selection):
'Existing config conflicts: ask; updates preserve edits as .new.'] 'Existing config conflicts: ask; updates preserve edits as .new.']
if selection['bootloader'] != 'keep': if selection['bootloader'] != 'keep':
lines.append('Bootloader package selected; ESP/firmware deployment is a separate explicit setup step (docs/boot.md).') lines.append('Boot deployment: target ESP and boot files are previewed before applying; firmware changes are separately selectable (docs/boot.md).')
if selection.get('firefox_privacy'): if selection.get('firefox_privacy'):
lines.append('Optional manual setup: Firefox-Privacy (docs/firefox.md).') lines.append('Optional manual setup: Firefox-Privacy (docs/firefox.md).')
if selection.get('spotify_custom'): if selection.get('spotify_custom'):
@ -354,6 +393,7 @@ def install_packages(selection):
profile.unlink() profile.unlink()
profile.symlink_to(Path('/etc/portage/make.profile').resolve()) profile.symlink_to(Path('/etc/portage/make.profile').resolve())
export(selection, stage) export(selection, stage)
fetched = stage_repositories(selection, stage)
make_conf = stage / 'etc/portage/make.conf' make_conf = stage / 'etc/portage/make.conf'
make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n') make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n')
# Repository checkout is local and read-only for resolution. No live # Repository checkout is local and read-only for resolution. No live
@ -392,6 +432,7 @@ def install_packages(selection):
return return
if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply': if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply':
return return
publish_repositories(fetched)
# Back up each existing managed destination before replacing it. # Back up each existing managed destination before replacing it.
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
backup_root = Path('/var/lib/Fiw-Gentoo-Dots/backups') / stamp backup_root = Path('/var/lib/Fiw-Gentoo-Dots/backups') / stamp
@ -456,7 +497,7 @@ def install_packages(selection):
raise RuntimeError('Some packages did not install; consult the package report and emerge output.') raise RuntimeError('Some packages did not install; consult the package report and emerge output.')
if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing: if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing:
subprocess.run(['systemctl', 'enable', '--force', 'plasmalogin.service'], check=True) subprocess.run(['systemctl', 'enable', '--force', 'plasmalogin.service'], check=True)
print('Portage installation finished. Plasma Login Manager is enabled for selected KDE installs; no running greeter was restarted. Flatpaks and boot deployment are explicit follow-up steps; see docs/setup.md.') print('Portage installation finished. Plasma Login Manager is enabled for selected KDE installs; no running greeter was restarted. Use the selected Flatpak, service and boot actions or the full restore workflow to finish setup.')
def check_packages(selection): def check_packages(selection):
@ -470,6 +511,7 @@ def check_packages(selection):
profile.unlink() profile.unlink()
profile.symlink_to(Path('/etc/portage/make.profile').resolve()) profile.symlink_to(Path('/etc/portage/make.profile').resolve())
export(selection, stage) export(selection, stage)
fetched = stage_repositories(selection, stage)
make_conf = stage / 'etc/portage/make.conf' make_conf = stage / 'etc/portage/make.conf'
make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n') make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n')
result = resolve(stage, selection, requested) result = resolve(stage, selection, requested)
@ -489,18 +531,28 @@ def main():
parser.add_argument('--catalog', action='store_true') parser.add_argument('--catalog', action='store_true')
parser.add_argument('--export', type=Path) parser.add_argument('--export', type=Path)
parser.add_argument('--apply-configs', action='store_true') parser.add_argument('--apply-configs', action='store_true')
parser.add_argument('--check-configs', action='store_true')
parser.add_argument('--install-packages', action='store_true') parser.add_argument('--install-packages', action='store_true')
parser.add_argument('--check-packages', action='store_true') parser.add_argument('--check-packages', action='store_true')
parser.add_argument('--install-flatpaks', action='store_true')
parser.add_argument('--enable-services', action='store_true')
parser.add_argument('--deploy-bootloader', action='store_true')
parser.add_argument('--boot-plan', action='store_true')
parser.add_argument('--esp', type=Path)
parser.add_argument('--update', action='store_true') parser.add_argument('--update', action='store_true')
parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask') parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask')
args = parser.parse_args() args = parser.parse_args()
if args.catalog: if args.catalog:
print(json.dumps({'groups': GROUPS, 'configs': CATALOG, 'extras': EXTRAS, print(json.dumps({'groups': GROUPS, 'configs': CATALOG, 'extras': EXTRAS,
'flatpaks': FLATPAKS, 'services': SERVICES,
'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')})) 'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')}))
return return
selection = load_selection(args.selection, args.profile) selection = load_selection(args.selection, args.profile)
if args.export: if args.export:
export(selection, args.export) export(selection, args.export)
elif args.check_configs:
check_configs(selection, args.home)
print('Config restoration preflight passed.')
elif args.apply_configs: elif args.apply_configs:
if os.geteuid() == 0: if os.geteuid() == 0:
raise RuntimeError('Apply user configs as the target user, not as root.') raise RuntimeError('Apply user configs as the target user, not as root.')
@ -509,6 +561,16 @@ def main():
install_packages(selection) install_packages(selection)
elif args.check_packages: elif args.check_packages:
check_packages(selection) check_packages(selection)
elif args.install_flatpaks:
install_flatpaks(selection, ask)
elif args.enable_services:
enable_services(selection, ask)
elif args.deploy_bootloader or args.boot_plan:
from boot import deploy, plan
if args.boot_plan:
print(json.dumps(plan(selection['bootloader'], args.esp), indent=2))
else:
deploy(selection['bootloader'], args.esp, ask)
else: else:
print(preview(selection)) print(preview(selection))

174
lib/setup.py Normal file
View file

@ -0,0 +1,174 @@
"""Optional repositories, Flatpaks and services for the restoration workflow."""
import json
import os
import re
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
REPOSITORIES = {
'guru': 'https://github.com/gentoo-mirror/guru.git',
'steam-overlay': 'https://github.com/anyc/steam-overlay.git',
}
FLATPAKS = {}
LABELS = {'org.localsend.localsend_app': 'LocalSend', 'org.vinegarhq.Sober': 'Sober'}
for list_file in sorted((Path(__file__).resolve().parents[1] / 'flatpaks').glob('*.list')):
for line in list_file.read_text().splitlines():
app = line.strip()
if not app or app.startswith('#'):
continue
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_.-]*', app) or app.count('.') < 2:
raise ValueError('Invalid Flatpak ID in ' + list_file.name)
FLATPAKS[app] = {'label': LABELS.get(app, app), 'group': list_file.stem}
SERVICES = {
'audio': {'label': 'PipeWire audio (user)', 'scope': 'user',
'units': ['pipewire.socket', 'pipewire-pulse.socket', 'wireplumber.service'],
'packages': ['media-video/pipewire', 'media-video/wireplumber']},
'network': {'label': 'NetworkManager (system)', 'scope': 'system',
'units': ['NetworkManager.service'], 'packages': ['net-misc/networkmanager']},
'bluetooth': {'label': 'Bluetooth (system)', 'scope': 'system',
'units': ['bluetooth.service'], 'packages': ['net-wireless/bluez']},
'power': {'label': 'Power profiles (system)', 'scope': 'system',
'units': ['power-profiles-daemon.service'], 'packages': ['sys-power/power-profiles-daemon']},
}
def required_repositories(selection):
names = []
if any(g in selection['groups'] for g in ('fiw-apps', 'gaming')):
names.append('guru')
if 'gaming' in selection['groups']:
names.append('steam-overlay')
return names
def existing_repository(name):
if not shutil.which('portageq'):
return None
result = subprocess.run(['portageq', 'get_repo_path', '/', name], text=True,
capture_output=True)
if result.returncode or not result.stdout.strip():
return None
path = Path(result.stdout.strip())
if (path / 'profiles/repo_name').is_file():
return path
return None
def repository_config(name, location):
return (f'[{name}]\nlocation = {location}\nsync-type = git\n'
f'sync-uri = {REPOSITORIES[name]}\nsync-depth = 1\npriority = 10\n')
def stage_repositories(selection, stage):
"""Fetch missing selected repositories into the temporary resolution root."""
fetched = {}
for name in required_repositories(selection):
if existing_repository(name):
continue
if not shutil.which('git'):
raise RuntimeError('Repository preparation needs Git. Install dev-vcs/git first.')
path = stage / 'var/db/repos' / name
path.parent.mkdir(parents=True, exist_ok=True)
print('Preparing missing repository in temporary storage: ' + name, flush=True)
subprocess.run(['git', 'clone', '--depth=1', REPOSITORIES[name], str(path)], check=True)
if (path / 'profiles/repo_name').read_text().strip() != name:
raise RuntimeError('Unexpected repository identity for ' + name)
config = stage / 'etc/portage/repos.conf' / ('fiw-dots-' + name + '.conf')
config.parent.mkdir(parents=True, exist_ok=True)
config.write_text(repository_config(name, path))
fetched[name] = path
return fetched
def publish_repositories(fetched):
"""Adopt verified checkouts only after the installation confirmation."""
for name, source in fetched.items():
target = Path('/var/db/repos') / name
if target.exists():
raise RuntimeError('Repository destination already exists; preserving it: ' + str(target))
target.parent.mkdir(parents=True, exist_ok=True)
scratch = target.with_name('.fiw-dots-' + name + '-' + str(os.getpid()))
try:
shutil.copytree(source, scratch, symlinks=True)
scratch.rename(target)
finally:
if scratch.exists():
shutil.rmtree(scratch)
def write_report(kind, report, system=False):
state = Path('/var/lib/Fiw-Gentoo-Dots') if system else Path.home() / '.local/state/Fiw-Gentoo-Dots'
state.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
path = state / (kind + '-' + stamp + '.json')
path.write_text(json.dumps(report, indent=2) + '\n')
print('Report: ' + str(path))
return path
def install_flatpaks(selection, ask):
if os.geteuid() == 0:
raise RuntimeError('Install user Flatpaks as the target user, not root.')
apps = selection.get('flatpaks', [])
if not apps:
print('No Flatpaks selected.')
return
if not shutil.which('flatpak'):
raise RuntimeError('Flatpak is missing. Install sys-apps/flatpak or select it through the package workflow.')
missing = [app for app in apps if subprocess.run(
['flatpak', 'info', '--user', app], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode]
report = {'selected': apps, 'already_installed': [app for app in apps if app not in missing],
'installed': [], 'failed': [], 'skipped': []}
print('User Flatpaks from Flathub: ' + (', '.join(missing) or 'all selected apps already installed'))
if missing and ask('Install these selected Flatpaks for your user?', ['install', 'skip'], 'install') == 'skip':
report['skipped'] = missing
elif missing:
remote = subprocess.run(['flatpak', 'remote-add', '--user', '--if-not-exists',
'flathub', 'https://flathub.org/repo/flathub.flatpakrepo'])
if remote.returncode:
report['failed'] = missing
else:
for app in missing:
result = subprocess.run(['flatpak', 'install', '--user', '--noninteractive',
'--assumeyes', 'flathub', app])
present = subprocess.run(['flatpak', 'info', '--user', app],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
report['installed' if not result.returncode and not present.returncode else 'failed'].append(app)
write_report('flatpaks', report)
print('Missing Flatpaks: ' + (', '.join(report['failed'] + report['skipped']) or 'none'))
if report['failed']:
raise RuntimeError('Some Flatpaks did not install; see the report.')
def enable_services(selection, ask):
system = os.geteuid() == 0
scope = 'system' if system else 'user'
units = list(dict.fromkeys(unit for key in selection.get('services', [])
if SERVICES[key]['scope'] == scope for unit in SERVICES[key]['units']))
if not units:
print('No ' + scope + ' services selected.')
return
command = ['systemctl'] + ([] if system else ['--user'])
report = {'scope': scope, 'selected': units, 'enabled': [], 'missing': [], 'failed': [], 'skipped': []}
available = []
for unit in units:
result = subprocess.run(command + ['show', '--property=LoadState', '--value', unit],
text=True, capture_output=True)
if result.returncode or result.stdout.strip() != 'loaded':
report['missing'].append(unit)
else:
available.append(unit)
print('Enable at next boot/login: ' + (', '.join(available) or 'none'))
if available and ask('Enable the selected ' + scope + ' services?', ['enable', 'skip'], 'enable') == 'skip':
report['skipped'] = available
else:
for unit in available:
result = subprocess.run(command + ['enable', unit])
report['enabled' if not result.returncode else 'failed'].append(unit)
write_report('services', report, system)
print('Missing service units: ' + (', '.join(report['missing']) or 'none'))
if report['failed']:
raise RuntimeError('Some services could not be enabled; see the report.')

View file

@ -31,5 +31,7 @@
"btrfs" "btrfs"
], ],
"firefox_privacy": false, "firefox_privacy": false,
"spotify_custom": false "spotify_custom": false,
"flatpaks": [],
"services": []
} }

View file

@ -26,5 +26,7 @@
"bootloader": "keep", "bootloader": "keep",
"extras": [], "extras": [],
"firefox_privacy": false, "firefox_privacy": false,
"spotify_custom": false "spotify_custom": false,
"flatpaks": [],
"services": []
} }

142
tests/test_setup.py Normal file
View file

@ -0,0 +1,142 @@
import importlib.util
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
LIB = Path(__file__).resolve().parents[1] / 'lib'
def module(name):
spec = importlib.util.spec_from_file_location(name, LIB / (name + '.py'))
value = importlib.util.module_from_spec(spec)
spec.loader.exec_module(value)
return value
setup = module('setup')
boot = module('boot')
class SetupTests(unittest.TestCase):
def test_only_selected_overlays_are_staged_and_existing_ones_are_preserved(self):
self.assertEqual(setup.required_repositories({'groups': ['kde']}), [])
self.assertEqual(setup.required_repositories({'groups': ['gaming']}), ['guru', 'steam-overlay'])
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
def clone(command, **kwargs):
destination = Path(command[-1])
(destination / 'profiles').mkdir(parents=True)
(destination / 'profiles/repo_name').write_text(destination.name + '\n')
return subprocess.CompletedProcess(command, 0)
with patch.object(setup, 'existing_repository', side_effect=lambda name: Path('/existing') if name == 'guru' else None), patch.object(setup.shutil, 'which', return_value='/usr/bin/git'), patch.object(setup.subprocess, 'run', side_effect=clone) as run:
fetched = setup.stage_repositories({'groups': ['gaming']}, root)
self.assertEqual(set(fetched), {'steam-overlay'})
self.assertEqual(run.call_count, 1)
config = root / 'etc/portage/repos.conf/fiw-dots-steam-overlay.conf'
self.assertIn(str(root / 'var/db/repos/steam-overlay'), config.read_text())
self.assertFalse((root / 'etc/portage/repos.conf/fiw-dots-guru.conf').exists())
def test_flatpak_failure_is_reported_and_installs_are_user_scoped(self):
reports = []
installed = set()
def run(command, **kwargs):
if command[:2] == ['flatpak', 'info']:
return subprocess.CompletedProcess(command, 0 if command[-1] in installed else 1)
if 'install' in command and command[-1] == 'org.localsend.localsend_app':
installed.add(command[-1])
return subprocess.CompletedProcess(command, 0)
if 'install' in command:
return subprocess.CompletedProcess(command, 1)
return subprocess.CompletedProcess(command, 0)
with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.shutil, 'which', return_value='/usr/bin/flatpak'), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report: reports.append(report)):
with self.assertRaises(RuntimeError):
setup.install_flatpaks({'flatpaks': list(setup.FLATPAKS)}, lambda *args: 'install')
self.assertEqual(reports[0]['installed'], ['org.localsend.localsend_app'])
self.assertEqual(reports[0]['failed'], ['org.vinegarhq.Sober'])
for call in commands.call_args_list:
self.assertIn('--user', call.args[0])
def test_missing_services_are_reported_and_enabled_without_starting(self):
reports = []
def run(command, **kwargs):
if 'show' in command:
state = 'not-found\n' if command[-1] == 'wireplumber.service' else 'loaded\n'
return subprocess.CompletedProcess(command, 0, state)
return subprocess.CompletedProcess(command, 0)
with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report, system: reports.append(report)):
setup.enable_services({'services': ['audio', 'network']}, lambda *args: 'enable')
self.assertEqual(reports[0]['missing'], ['wireplumber.service'])
self.assertEqual(reports[0]['enabled'], ['pipewire.socket', 'pipewire-pulse.socket'])
for call in commands.call_args_list:
self.assertNotIn('--now', call.args[0])
self.assertIn('--user', call.args[0])
class BootTests(unittest.TestCase):
def test_managed_limine_block_preserves_other_entries_and_rejects_bad_markers(self):
original = 'timeout: 8\n\n/Other OS\n protocol: efi\n path: boot():/other.efi\n'
block = boot.render_limine([{'version': 'test-gentoo'}], 'root=UUID=target-root ro')
merged = boot.merge_limine(original, block)
self.assertTrue(merged.startswith(original))
self.assertEqual(boot.merge_limine(merged, block), merged)
with self.assertRaises(RuntimeError):
boot.merge_limine(original + boot.BEGIN, block)
def test_edited_limine_entries_get_proposal_while_external_entries_remain(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
image = root / 'kernel';image.write_bytes(b'kernel')
initrd = root / 'initrd';initrd.write_bytes(b'initrd')
images = [{'version': 'test-gentoo', 'kernel': str(image), 'initrd': str(initrd)}]
saved = {'mode': 'limine', 'esp': str(root / 'esp'), 'cmdline': 'root=UUID=target-root ro'}
(root / 'esp').mkdir()
with patch.object(boot, 'discover_esp', return_value={'path': saved['esp']}), patch.object(boot, 'kernel_images', return_value=images):
saved = boot.refresh(saved, root / 'backups')
config = root / 'esp/EFI/Fiw-Gentoo/limine.conf'
config.write_text(config.read_text() + '\n/Other OS\n protocol: efi\n')
saved = boot.refresh(saved, root / 'backups')
self.assertIn('/Other OS', config.read_text())
config.write_text(config.read_text().replace('target-root', 'user-edited-root'))
previous = config.read_text()
boot.refresh(saved, root / 'backups')
self.assertEqual(config.read_text(), previous)
proposals = list(config.parent.glob('limine.conf.new.*'))
self.assertEqual(len(proposals), 1)
self.assertIn('/Other OS', proposals[0].read_text())
def test_failed_grub_generation_does_not_replace_existing_menu(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
folder = root / 'boot';(folder / 'grub').mkdir(parents=True)
config = folder / 'grub/grub.cfg';config.write_text('working config\n')
images = []
def run(command, **kwargs):
Path(command[-1]).write_text('partial failed config')
raise subprocess.CalledProcessError(1, command)
# /boot sources prevent normalization writes outside the staging dir.
images = [{'version': 'test', 'kernel': '/boot/kernel-test', 'initrd': '/boot/initramfs-test.img'}]
saved = {'mode': 'grub', 'esp': str(root), 'config_hash': boot.file_hash(config)}
with patch.object(boot, 'GRUB_DIRECTORY', folder), patch.object(boot, 'discover_esp', return_value={'path': str(root)}), patch.object(boot, 'kernel_images', return_value=images), patch.object(boot.subprocess, 'run', side_effect=run):
with self.assertRaises(subprocess.CalledProcessError):
boot.refresh(saved, root / 'backups')
self.assertEqual(config.read_text(), 'working config\n')
self.assertFalse(list(config.parent.glob('.*.tmp')))
def test_firmware_add_preserves_bootorder_and_repeat_default_does_not_duplicate(self):
target = {'disk': '/dev/testdisk', 'partition': 2, 'partuuid': 'target-partition'}
loader = '/EFI/Fiw-Gentoo/BOOTX64.EFI'
command = boot.firmware_commands(target, 'limine', loader, 'add', '')[0]
self.assertIn('--create-only', command)
self.assertNotIn('--create', command)
listing = 'BootOrder: 0001,0002\nBoot0002* Fiw-Gentoo-limine HD(2,GPT,target-partition)/File(\\EFI\\Fiw-Gentoo\\BOOTX64.EFI)\n'
commands = boot.firmware_commands(target, 'limine', loader, 'default', listing)
self.assertEqual(commands, [['efibootmgr', '--bootorder', '0002,0001']])
self.assertEqual(boot.firmware_commands(target, 'limine', loader, 'add', listing), [])
self.assertEqual(boot.plan('keep'), {'mode': 'keep', 'changes': []})
if __name__ == '__main__':
unittest.main()