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">
<img src="assets/images/Gentoo.png" alt="Gentoo logo" width="110" />
# Fiw-Gentoo-Dots
**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.
**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.
## Start
@ -41,10 +43,12 @@ cd Fiw-Gentoo-Dots
```
The Bubble Tea interface offers a preset, package sections, individual app
configs, kernel/bootloader choices, extras, and a final preview. Enter at the
preview saves a selection without applying it. `a` restores configs; `i`
starts the package installation workflow with sudo. Go 1.24+ builds the TUI;
Python 3.11+ runs the backend. Go dependencies are recorded in go.mod/go.sum.
configs, kernel/bootloader choices, individual Flatpaks, optional services and
a final preview. Enter saves without applying. `r` runs the full restore;
`a` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
`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:
@ -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 |
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
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
@ -136,6 +142,10 @@ python3 -m unittest discover -s tests
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.
This repo starts with fresh history. Third-party theme/cursor assets retain
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"`
Firefox bool `json:"firefox_privacy"`
Spotify bool `json:"spotify_custom"`
Flatpaks []string `json:"flatpaks"`
Services []string `json:"services"`
}
type config struct {
Label string `json:"label"`
Group string `json:"group"`
}
type setupOption struct {
Label string `json:"label"`
Group string `json:"group"`
Scope string `json:"scope"`
}
type catalog struct {
Groups []string `json:"groups"`
Configs map[string]config `json:"configs"`
Extras map[string]string `json:"extras"`
Flatpaks map[string]setupOption `json:"flatpaks"`
Services map[string]setupOption `json:"services"`
Stock selection `json:"stock"`
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{"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() {
@ -155,6 +180,19 @@ func (m *model) remember() {
}
}
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 {
@ -195,7 +233,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.loading {
return m, nil
}
if m.stage == 4 {
if m.stage == 5 {
switch key {
case "up", "k":
if m.scroll > 0 {
@ -208,7 +246,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "esc":
m.stage--
m.prepare()
case "enter", "s", "a", "i":
case "enter", "s", "a", "i", "f", "v", "b", "r":
if m.errorText == "" {
m.done = true
m.action = key
@ -267,7 +305,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.remember()
m.stage++
if m.stage == 4 {
if m.stage == 5 {
m.loading = true
m.errorText = ""
return m, m.makePreview()
@ -287,13 +325,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) View() string {
const purple = "\033[38;2;157;143;217m"
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"
budget := m.height - 10
if budget < 5 {
budget = 5
}
if m.stage == 4 {
if m.stage == 5 {
if m.loading {
return text + " Preparing preview…\n"
}
@ -305,7 +343,7 @@ func (m model) View() string {
for _, line := range lines[m.scroll:end] {
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 != "" {
text += " " + m.errorText + "\n"
}
@ -355,6 +393,14 @@ func main() {
os.Exit(1)
}
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()
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
@ -377,27 +423,75 @@ func main() {
}
fmt.Println(final.preview)
fmt.Println("\nSaved selection:", path)
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path}
switch final.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
}
run := func(action string, root bool) error {
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, action}
program := "python3"
if final.action == "i" {
if root {
program = "sudo"
args = append([]string{"python3"}, args...)
}
cmd := exec.Command(program, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}
services := func() error {
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)
}
}

View file

@ -50,3 +50,19 @@ func TestConfigsSelectableWithoutPackageGroups(t *testing.T) {
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
updating tools/public-attribution.json. The fingerprinted upstream kernel
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.
The draft does not install firmware entries or replace the active bootloader.
Target-specific deployment still needs implementation and a reviewed target
ESP/root layout. Selecting a kernel does not select a bootloader.
`keep` is the default. Limine and GRUB have separate package choices; kernel
selection remains independent. Selecting a loader does not itself deploy it.
Use the final preview's **b** action or include it in **r** full restore.
GRUB integration must use the target system's installkernel configuration
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.
## Preview and apply
The retained `optional/fiw-limine-splice` refreshes the Gentoo block in an
existing Limine configuration and preserves other entries. It is specific
to the current marker/snippet design and is not installed by the default
rice preset or by the GRUB variant.
```sh
./install --selection local/selection.json --boot-plan
sudo ./install --selection local/selection.json --deploy-bootloader
# 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
generates a CachyOS patch and updates the version guard/name. It requires
root and matching upstream source releases; review before running.
Discovery checks mounted `/efi`, `/boot/efi` and `/boot` candidates for the GPT
ESP type and FAT filesystem. The plan shows the selected partition, root
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
Plasma profile. This repo does not partition disks or install the stage3.
Plasma Login Manager currently requires systemd. It is the default greeter
when installing the KDE section.
Plasma profile, with Git and Python 3.11+ available. Go 1.24+ builds the TUI.
This repo does not partition disks or install a stage3.
1. Run `./install`, make selections and review the final preview.
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.
## Terminal interface
For updates, review the Git changes, then use the saved selection with
`--apply-configs --update`. There is no automatic git pull or upstream upgrade.
Backups and reports live under `~/.local/state/Fiw-Gentoo-Dots`. Package reports
and system backups live under `/var/lib/Fiw-Gentoo-Dots`.
Run `./install`. Choose a preset, package sections, individual configs,
kernel/bootloader options, Flatpaks and optional services. Previous selections
are loaded from the ignored `local/selection.json`; press Esc to return to
preset selection when you want to change profiles.
## Explicit setup steps
At the final preview:
Root package installation enables `plasmalogin.service` for selected KDE
installs without restarting the running greeter. After the desired packages
are installed, enable the services you use:
| Key | Action |
|---|---|
| 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
sudo systemctl enable NetworkManager bluetooth power-profiles-daemon
systemctl --user enable pipewire.socket pipewire-pulse.socket wireplumber.service
./install --selection local/selection.json --plan
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
the selected categories' apps explicitly, for example:
## Packages and repositories
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
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install --user flathub org.localsend.localsend_app
flatpak install --user flathub org.vinegarhq.Sober
./install --selection local/selection.json --check-packages
./install --selection local/selection.json --export local/stage
```
The independently selectable `fonts` config includes the four used styles of
JetBrainsMono Nerd Font Mono from the official Nerd Fonts v3.5.1 release,
with its OFL licence. It works with KDE and portable apps alike. The entire
personal font collection is not included. Neovim uses its built-in theme as
a fallback when Catppuccin is not installed. Vesktop's selected Midnight
theme CSS is captured with a fixed upstream snapshot and its MIT licence.
The check may download a missing overlay into its temporary root; it does not
register that checkout or change the live Portage configuration. The export
contains proposed files and a plan, not a ready-to-merge repository checkout.
Plasma Login Manager is enabled for selected KDE installs. The installer does
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
The `fiw-tools` section includes FiwNode's x86-64 binary release and the
released Apdatifier Gentoo widget, alongside OpenDeck and Music Presence.
Apdatifier installs QML/scripts without compilation; any dependencies needing
source builds still go through the normal compile/skip prompt.
The `fiw-tools` section includes FiwNode's binary release and the released
Apdatifier Gentoo widget, alongside OpenDeck and Music Presence. Apdatifier
installs QML/scripts without compilation; source-only dependencies still
require the normal compile/skip choice.
FiwNode uses its default configuration. The installer does not copy sound
libraries, microphone/output selections or personal app state. Older copies
in `~/.local/bin` can shadow the Portage-installed binaries in `/usr/bin`;
remove those manually when ready to switch. The daemon starts on demand.
FiwNode uses its default config. Sound libraries and audio device selections
stay local. Copies in `~/.local/bin` can shadow `/usr/bin`; manage old copies
manually when switching to the package. Its daemon starts on demand.
Apdatifier is available through Plasma's **Add Widgets** menu. Installing it
does not alter panels. An existing user-installed widget can override the
system copy; use `kpackagetool6` to manage it. Its preferences remain local.
Add Apdatifier through Plasma's **Add Widgets** menu. Installation does not
alter panels. An existing user-installed widget can override the system copy;
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
has not been exercised on the working desktop.
- Implement target-specific Limine/GRUB deployment after reviewing the target
ESP and boot layout. Current variants select packages and provide setup notes.
- 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.
Automatic boot deployment supports amd64 UEFI, a mounted GPT ESP, plain ext4
or Btrfs roots, and unsigned boot. See [boot setup](boot.md) for previews,
firmware choices and kernel update integration. Other layouts can keep their
existing loader.
Do not publish the private original fiw-gentoo repository. It is the working
archive and has different history and host-specific files.
The optional TideWM live recipe still needs a refresh against its upstream
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]
sys.path.insert(0, str(REPO / 'tools'))
sys.path.insert(0, str(REPO / 'lib'))
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']
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())
if selection.get('profile') not in ('stock', 'fiw-ryzen'):
raise ValueError('Unknown preset')
for field, allowed in [('groups', GROUPS), ('configs', CATALOG), ('extras', EXTRAS)]:
if any(x not in allowed for x in selection.get(field, [])):
for field in ('flatpaks', 'services'):
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)
if selection.get('kernel') not in ('binary', 'custom'):
raise ValueError('Unknown kernel choice')
@ -54,6 +64,17 @@ def package_map(selection):
result[extra] = atoms(REPO / 'packages/optional' / (extra + '.list'))
if selection['bootloader'] != 'keep':
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
@ -153,11 +174,16 @@ def active_plasma(home):
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'):
home = home.resolve()
entries = list(config_entries(selection))
if any(CATALOG[name].get('kde') for name, _, _, _ in entries) and active_plasma(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.')
check_configs(selection, home)
state_dir = home / '.local/state/Fiw-Gentoo-Dots'
index_path = state_dir / 'managed.json'
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'] += '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'
for name, uri in [('guru', 'https://github.com/gentoo-mirror/guru.git'), ('steam-overlay', 'https://github.com/anyc/steam-overlay.git')]:
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'
for name in required_repositories(selection):
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'
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']:
@ -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/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'
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']:
result['etc/portage/package.accept_keywords/fiw-dots-tidewm'] = 'gui-wm/tidewm::fiw-dots **\n'
return result
@ -275,7 +310,11 @@ def preview(selection):
lines = ["Fiw-Gentoo-Dots — " + selection['name'], '']
for group, values in packages.items():
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.',
'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''),
'Bootloader: ' + selection['bootloader'], '', 'Selected configs:']
@ -286,7 +325,7 @@ def preview(selection):
'Existing config conflicts: ask; updates preserve edits as .new.']
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'):
lines.append('Optional manual setup: Firefox-Privacy (docs/firefox.md).')
if selection.get('spotify_custom'):
@ -354,6 +393,7 @@ def install_packages(selection):
profile.unlink()
profile.symlink_to(Path('/etc/portage/make.profile').resolve())
export(selection, stage)
fetched = stage_repositories(selection, stage)
make_conf = stage / 'etc/portage/make.conf'
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
@ -392,6 +432,7 @@ def install_packages(selection):
return
if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply':
return
publish_repositories(fetched)
# Back up each existing managed destination before replacing it.
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
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.')
if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing:
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):
@ -470,6 +511,7 @@ def check_packages(selection):
profile.unlink()
profile.symlink_to(Path('/etc/portage/make.profile').resolve())
export(selection, stage)
fetched = stage_repositories(selection, stage)
make_conf = stage / 'etc/portage/make.conf'
make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n')
result = resolve(stage, selection, requested)
@ -489,18 +531,28 @@ def main():
parser.add_argument('--catalog', action='store_true')
parser.add_argument('--export', type=Path)
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('--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('--conflict', choices=['ask', 'keep', 'apply'], default='ask')
args = parser.parse_args()
if args.catalog:
print(json.dumps({'groups': GROUPS, 'configs': CATALOG, 'extras': EXTRAS,
'flatpaks': FLATPAKS, 'services': SERVICES,
'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')}))
return
selection = load_selection(args.selection, args.profile)
if args.export:
export(selection, args.export)
elif args.check_configs:
check_configs(selection, args.home)
print('Config restoration preflight passed.')
elif args.apply_configs:
if os.geteuid() == 0:
raise RuntimeError('Apply user configs as the target user, not as root.')
@ -509,6 +561,16 @@ def main():
install_packages(selection)
elif args.check_packages:
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:
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"
],
"firefox_privacy": false,
"spotify_custom": false
"spotify_custom": false,
"flatpaks": [],
"services": []
}

View file

@ -26,5 +26,7 @@
"bootloader": "keep",
"extras": [],
"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()