diff --git a/README.md b/README.md
index 57a84f5..04987ae 100644
--- a/README.md
+++ b/README.md
@@ -9,178 +9,109 @@



-
-[KDE styling](docs/kde-style.md) · [Shortcuts](docs/shortcuts.md) · [Packages](#package-sections) · [Setup](docs/setup.md)
+[Setup](docs/setup.md) · [Shortcuts](docs/shortcuts.md) · [Styling](docs/kde-style.md) · [Packages](#packages)
----
+## About
-## Overview
+My personal Gentoo restoration kit, mainly for my own devices:
-A personal Gentoo rice and restoration kit: my Plasma styling, current
-shortcuts, package lists and the app preferences I use every day. Built
-mainly for my own devices, with deliberate updates and a preview before
-anything is applied.
+- KDE colours, fonts, cursor, window styling and my exact keybinds.
+- Separate package lists and individually optional app configs.
+- Fish, Kitty and Neovim preferences that work across compositors.
+- Binaries preferred, with a small deliberate source-build list.
+- A Bubble Tea chooser with a final preview, conflict prompts and backups.
-- **KDE first:** colours, fonts, cursor, window styling and current keybinds.
-- **Pick what you want:** package sections and individually optional app configs.
-- **Portable terminals:** Fish, Kitty and Neovim work across compositors.
-- **Binaries preferred:** a small source list, with a choice before extra builds.
-- **Keep your edits:** conflict prompts, backups and `.new` proposals on updates.
+Package and boot actions still need a first run on a fresh Gentoo installation.
-**Work in progress:** configuration restoration and the TUI are ready for review.
-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.
+## Screenshots
+
+### Desktop
+
+
+
+My running desktop. Wallpapers and panels stay local; the saved Fastfetch
+preset uses the built-in Gentoo logo.
+
+### Installer
+
+
## Start
```sh
-git clone --branch v0.1.2 https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
+git clone https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
cd Fiw-Gentoo-Dots
./install
```
-The Bubble Tea interface offers a preset, package sections, individual app
-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.
-`e` updates configs while preserving local edits; `p` opens pending changes
-for a diff and individual acceptance. `u` opens config backups for preview
-and restoration. `n` saves a [named device selection](docs/devices.md), available
-alongside Stock and Fiw's Ryzen on the preset screen. Apply actions finish
-with a [combined report](docs/reports.md) of outcomes and remaining setup.
-Linux amd64 includes a verified prebuilt TUI; Git and Python 3.11+ are enough.
-Go 1.24+ is the source fallback for changed frontend code or other platforms.
-Saved selections are reused on subsequent launches. See the
-[snapshot and build instructions](docs/releases.md).
+Start from an installed Gentoo system. Linux amd64 includes a prebuilt TUI;
+Git and Python 3.11+ are enough. Go 1.24+ is needed only for source builds.
+Choose your packages/configs, review the plan, then select an action.
+Enter saves your choices without applying them.
-Forgejo is the primary repository; [GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots)
-receives branches and tags through the [CI mirror workflow](docs/ci.md) after
-the checks pass.
-
-For a preview without building the TUI:
-
-```sh
-./install --plan --profile stock
-./install --plan --profile fiw-ryzen
-```
-
-See [setup](docs/setup.md) for installation steps and draft limitations.
+See [setup and TUI keys](docs/setup.md), [tagged snapshots](docs/releases.md),
+[saved devices](docs/devices.md), [config updates](docs/updates.md) and
+[backup restoration](docs/backups.md).
## Presets
| Preset | Build settings | Kernel |
|---|---|---|
-| Stock | Portable compiler settings; binary packages preferred | Generic Gentoo binary kernel |
-| Fiw's Ryzen | Current Ryzen 9900X/Zen 5 tuning and NVIDIA configuration | Tested custom kernel plus generic binary fallback |
+| Stock | Portable settings, binaries preferred | Generic Gentoo binary kernel |
+| Fiw's Ryzen | Ryzen 9900X / Zen 5 tuning and NVIDIA | Custom kernel + binary fallback |
-Both deliberately compile Fastfetch, jq and zip. Custom kernels and their
-matching external modules compile when selected. Other packages prefer
-Portage binaries or upstream `-bin` packages. Additional source builds
-require a compile-or-skip choice, with compilation recommended; skipped
-dependencies also cause dependent requested applications to be skipped.
+Both compile Fastfetch, jq and zip. Selected custom kernels and modules also
+compile. Other source builds require a compile-or-skip choice; missing/skipped
+packages appear in the [final report](docs/reports.md).
-The custom kernel snapshot currently targets 7.2.8 with the tested
-CachyOS 7.2.7-1 patch. Refresh it explicitly when updating the snapshot.
-
-## Package sections
+## Packages
| List | Contents |
|---|---|
-| [kde](packages/kde.list) | Plasma, Plasma Login Manager, desktop components and supporting fonts |
-| [fiw-apps](packages/fiw-apps.list) | Everyday apps including Dolphin, Ark, Gwenview and Spectacle |
-| [cli](packages/cli.list) | Shell, terminal utilities and FFmpeg |
+| [kde](packages/kde.list) | Plasma, Plasma Login Manager and desktop components |
+| [fiw-apps](packages/fiw-apps.list) | Everyday apps, including Dolphin, Ark, Gwenview and Spectacle |
+| [cli](packages/cli.list) | Shell and terminal utilities |
| [dev](packages/dev.list) | Languages and development tools |
-| [gaming](packages/gaming.list) | Steam, Proton, Prism, MangoHud and r2modman; Sober is in flatpaks/gaming.list |
-| [system](packages/system.list) | System utilities; the selected kernels are added by the installer |
-| [fiw-tools](packages/fiw-tools.list) | FiwNode binary release, Apdatifier Gentoo, OpenDeck and Music Presence |
-| [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities |
+| [gaming](packages/gaming.list) | Steam, Proton, Prism, MangoHud and r2modman |
+| [system](packages/system.list) | System utilities; kernels follow your selection |
+| [fiw-tools](packages/fiw-tools.list) | FiwNode, Apdatifier Gentoo, OpenDeck and Music Presence |
+| [tidewm](packages/tidewm.list) | Optional compositor and supporting tools |
-Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not
-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. Deployment
-previews the target ESP and firmware choices; see [boot setup](docs/boot.md).
-The [package audit](docs/package-audit.md) accounts for all current explicit
-selections. [TideWM](docs/tidewm.md) remains an optional source build.
+Filelight, NVIDIA and Btrfs tools are separate extras. Sober and LocalSend are
+individually optional Flatpaks. Limine and GRUB are optional; keeping the
+existing bootloader is the default. See [boot setup](docs/boot.md).
## Configs
-Every config is independently selectable, even when its package section is
-unchecked—for example, to configure apps already installed. KDE appearance covers
-colours, fonts, cursor and window decorations, with matching GTK preferences.
-Dolphin styling has its own checkbox. Panels, widgets and
-wallpapers are excluded. All portable current shortcut assignments,
-including disabled defaults, are captured with their launchers and helpers;
-unused machine-specific activity IDs are omitted.
+Every [app config](docs/app-configs.md) is independently optional. KDE styling
+includes matching GTK preferences; autostart has its own checkbox.
+Neovim's Catppuccin theme, VS Code's ayu MiDas theme, fonts and cursor assets
+are bundled with their licences. Accounts and sessions stay local.
-Fish, Kitty and Neovim are usable without KDE or KWin. Fastfetch keeps the
-tree layout and purple colours with the built-in Gentoo logo. MangoHud and
-Vesktop preferences are included; browser profiles, chat sessions and account
-data are excluded. App autostart is a separate optional selection.
-Neovim's Catppuccin and VS Code's ayu MiDas themes are bundled with their
-respective optional configs, so their styling is available on fresh devices.
-Ark, Gwenview and Prism preferences are individually optional too; see
-[app configs and requirements](docs/app-configs.md). Each config's required
-packages appear in the final preview without changing package selections.
-
-Spotify is stock by default. The existing customization scripts remain an
-[explicit optional step](docs/spotify.md). [Firefox-Privacy](docs/firefox.md)
-is also optional.
-
-Existing differences prompt apply-or-keep and get backups when replaced.
-Updates leave locally edited configs intact and write the proposal to `.new`.
-The TUI can [review and accept each proposal](docs/updates.md), backing up the
-current file and refusing proposals whose target changed since creation.
-KConfig patches preserve unrelated keys and groups; JSON patches preserve
-unrelated preferences and login data already on the target device.
-The TUI can [restore user config backups](docs/backups.md), with a preview,
-confirmation and a new backup of the replaced current files.
-
-## Project layout
-
-```text
-cmd/dots/ Bubble Tea selection interface
-configs/ Optional KDE and app configs
-packages/ Categorized Portage package lists
-presets/ Stock and Fiw's Ryzen defaults
-portage/ Build settings and binary preferences
-overlay/ Selected additional packages
-optional/ Kernel, boot and app helpers
-flatpaks/ Separate optional Flatpak lists
-docs/ Setup, decisions and asset credits
-```
+Local edits are preserved on updates, with proposed changes saved as `.new`
+for review. Replaced files get backups. Spotify stays stock;
+[customization](docs/spotify.md) and [Firefox-Privacy](docs/firefox.md) are
+explicit optional steps.
## Maintenance
```sh
-python3 tools/capture.py # recapture only the explicit allowlist
-python3 tools/capture.py --config kde-shortcuts # refresh only shortcuts
-python3 tools/capture.py --config kitty neovim # refresh only these apps
+python3 tools/capture.py --config kde-shortcuts
+python3 tools/capture.py --config kitty neovim
python3 tools/check-private.py
-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.
-See [selective recapture](docs/capture.md) for supported selections and pinned assets.
-This repo starts with fresh history. Third-party theme/cursor assets retain
-their licence notices; overlay ebuilds retain their original notices.
+Review `git diff` before committing. See [capture options](docs/capture.md)
+and [helper commands](docs/scripts.md). Device choices and local files are
+gitignored. Forgejo is primary; [GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots)
+receives branches and tags automatically after [CI checks](docs/ci.md).
## License
-Original code and configuration contributions are licensed under [MIT](LICENSE).
-Bundled third-party assets, fonts, patches and ebuilds keep their own licences
-and attribution; see [asset credits](docs/assets.md).
-
----
+Original code and configs: [MIT](LICENSE). Bundled third-party assets keep
+their own licences; see [credits](docs/assets.md).
A personal Gentoo setup by Fiw.
diff --git a/assets/Screenshots/Install-script.png b/assets/Screenshots/Install-script.png
new file mode 100644
index 0000000..10e30a9
Binary files /dev/null and b/assets/Screenshots/Install-script.png differ
diff --git a/assets/Screenshots/Main-screen.png b/assets/Screenshots/Main-screen.png
new file mode 100644
index 0000000..c19a574
Binary files /dev/null and b/assets/Screenshots/Main-screen.png differ
diff --git a/cmd/dots/main_test.go b/cmd/dots/main_test.go
deleted file mode 100644
index 4cf2903..0000000
--- a/cmd/dots/main_test.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package main
-
-import (
- tea "github.com/charmbracelet/bubbletea"
- "strings"
- "testing"
-)
-
-func TestRyzenSelectionSurvivesSpaceAndEnter(t *testing.T) {
- c := catalog{Stock: selection{Name: "Stock", Profile: "stock"}, Ryzen: selection{Name: "Fiw's Ryzen", Profile: "fiw-ryzen"}}
- m := model{catalog: c, selection: c.Stock}
- m.prepare()
- m.cursor = 1
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
- m = next.(model)
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if m.selection.Profile != "fiw-ryzen" || m.stage != 1 {
- t.Fatalf("selected wrong preset: %+v", m.selection)
- }
-}
-
-func TestBootloaderChoicesAreExclusive(t *testing.T) {
- m := model{stage: 3, selection: selection{Kernel: "binary", Bootloader: "keep"}, catalog: catalog{Extras: map[string]string{}}}
- m.prepare()
- m.cursor = 4
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
- m = next.(model)
- m.remember()
- if m.selection.Bootloader != "grub" {
- t.Fatal("GRUB was not selected")
- }
- selected := 0
- for _, r := range m.rows {
- if r.checked && (r.id == "keep" || r.id == "limine" || r.id == "grub") {
- selected++
- }
- }
- if selected != 1 {
- t.Fatal("multiple bootloaders selected")
- }
-}
-
-func TestConfigsSelectableWithoutPackageGroups(t *testing.T) {
- m := model{stage: 2, catalog: catalog{Configs: map[string]config{
- "fish": {Label: "Fish", Group: "cli"},
- "kitty": {Label: "Kitty", Group: "fiw-apps"},
- }}, selection: selection{Configs: []string{"fish"}}}
- m.prepare()
- if len(m.rows) != 2 || !m.rows[0].checked {
- 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)
- }
-}
-
-func TestBackupNeedsSelectionPreviewAndExplicitRestoreAction(t *testing.T) {
- m := model{stage: 5}
- next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}})
- m = next.(model)
- if m.stage != 6 || command == nil || m.done {
- t.Fatal("backup key did not open the chooser")
- }
- next, _ = m.Update(backupsMsg{choices: []backupChoice{{ID: "20260101T000000Z", Count: 1}}})
- m = next.(model)
- next, command = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if m.stage != 7 || command == nil || m.done {
- t.Fatal("choosing a backup must only open its preview")
- }
- next, _ = m.Update(previewMsg{content: "preview"})
- m = next.(model)
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
- m = next.(model)
- if m.done {
- t.Fatal("config application key triggered a restore from backup preview")
- }
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if !m.done || m.action != "u" || m.backupID != "20260101T000000Z" {
- t.Fatal("explicit backup action was lost")
- }
-}
-
-func TestEmptyBackupChooserDoesNotStartRestore(t *testing.T) {
- m := model{stage: 6}
- next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if m.done || command != nil || m.stage != 6 {
- t.Fatal("empty backup chooser started a restore")
- }
-}
-
-func TestPreviewCanScrollThroughLongRequirementLines(t *testing.T) {
- m := model{stage: 5, height: 20, width: 40, preview: strings.Repeat("package [missing] ", 20)}
- for range 3 {
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
- m = next.(model)
- }
- if m.scroll != 3 {
- t.Fatal("wrapped requirements cannot be scrolled into view")
- }
-}
-
-func TestProposalNeedsDiffAndExplicitAcceptance(t *testing.T) {
- m := model{stage: 5}
- next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}})
- m = next.(model)
- if m.stage != 8 || command == nil || m.done {
- t.Fatal("proposal key did not open the chooser")
- }
- next, _ = m.Update(proposalsMsg{choices: []proposalChoice{{ID: ".config/app.new", Target: ".config/app", Status: "ready"}}})
- m = next.(model)
- next, command = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if m.stage != 9 || command == nil || m.done {
- t.Fatal("choosing a proposal must only open its diff")
- }
- next, _ = m.Update(previewMsg{content: "Config update proposal: .config/app.new\nTarget: .config/app\nStatus: ready\n\n-preview\n+proposal"})
- m = next.(model)
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
- m = next.(model)
- if m.done {
- t.Fatal("application key accepted a proposal")
- }
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if !m.done || m.action != "p" || m.proposalID != ".config/app.new" {
- t.Fatal("explicit proposal acceptance was lost")
- }
-}
-
-func TestProposalThatBecameStaleCannotBeAcceptedFromPreview(t *testing.T) {
- m := model{stage: 9, proposalStatus: "ready", loading: true}
- next, _ := m.Update(previewMsg{content: "Config update proposal: .config/app.new\nTarget: .config/app\nStatus: stale\n"})
- m = next.(model)
- next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if m.done || command != nil || !strings.Contains(m.View(), "Target changed") {
- t.Fatal("stale proposal was offered for acceptance")
- }
-}
-
-func TestConfigUpdateIsASeparateAction(t *testing.T) {
- m := model{stage: 5}
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
- m = next.(model)
- if !m.done || m.action != "e" {
- t.Fatal("config update action was lost")
- }
-}
-
-func TestNamedDeviceKeepsItsSelections(t *testing.T) {
- device := selection{Name: "portable", Profile: "stock", Groups: []string{"cli"}, Configs: []string{"neovim"}, Kernel: "binary", Bootloader: "keep"}
- m := model{catalog: catalog{Stock: selection{Name: "Stock", Profile: "stock"}, Ryzen: selection{Name: "Ryzen", Profile: "fiw-ryzen"}, Devices: map[string]selection{"portable": device}}, selection: device}
- m.prepare()
- if m.cursor != 2 || !m.rows[2].checked || m.rows[0].checked {
- t.Fatal("saved device is not selected in the preset chooser")
- }
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if m.stage != 1 || len(m.selection.Groups) != 1 || !contains(m.selection.Configs, "neovim") || m.selection.Name != "portable" {
- t.Fatal("device choices were replaced by the stock preset")
- }
-}
-
-func TestDeviceNameAcceptsQAndRequiresExplicitSave(t *testing.T) {
- m := model{stage: 5}
- next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
- m = next.(model)
- if m.stage != 10 || m.done {
- t.Fatal("n must open the name input without saving")
- }
- next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
- m = next.(model)
- if command != nil || m.deviceName != "q" {
- t.Fatal("q should be accepted as part of a device name")
- }
- next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
- m = next.(model)
- if !m.done || m.action != "n" || m.selection.Name != "q" {
- t.Fatal("explicit named-device save was lost")
- }
-}
-
-func TestDeviceNameEscapeDoesNotSave(t *testing.T) {
- m := model{stage: 10, deviceName: "discard"}
- next, command := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
- m = next.(model)
- if m.stage != 5 || m.done || command != nil {
- t.Fatal("cancelled device input saved a selection")
- }
-}
diff --git a/docs/boot.md b/docs/boot.md
index 905aafa..96cd5f5 100644
--- a/docs/boot.md
+++ b/docs/boot.md
@@ -74,11 +74,5 @@ 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.
+a bootloader. See the [helper list](scripts.md) before using it.
diff --git a/docs/ci.md b/docs/ci.md
index 55e57bd..a5fcfbb 100644
--- a/docs/ci.md
+++ b/docs/ci.md
@@ -7,7 +7,7 @@ Its `.forgejo/workflows/ci.yml` runs these checks:
- Publishable files pass the privacy scanner.
- The prebuilt TUI matches `VERSION`, its frontend source and binary checksums.
- Pinned editor assets match their recorded fingerprints.
-- Python and Go checks pass using temporary homes and fixtures.
+- Python sources parse and the Go frontend compiles.
Successful push or manual runs then synchronize Git branches and tags to
[Fi3w0/Fiw-Gentoo-Dots on GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots).
diff --git a/docs/package-audit.md b/docs/package-audit.md
deleted file mode 100644
index 118234e..0000000
--- a/docs/package-audit.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Explicit package audit
-
-The 2026-10-03 audit compares Portage `world` and the selected `@fiw-*` sets
-with the main lists, optional lists and kernel choices. It reads explicit
-selections only; it does not expand package dependencies or copy an installed
-package inventory.
-
-Result: **111 explicit selections accounted for**. 110 have package-list or
-kernel coverage. `app-admin/fiw-scripts` supplies the two legacy helpers
-already retained under `optional/`; that exception is documented in
-`packages/audit-exceptions.json`. There are no unexplained missing packages
-or unresolved sets.
-
-The audit added `x11-misc/ydotool` to `cli`. It does not enable its daemon or
-change input permissions. Filelight, Btrfs tools, NVIDIA drivers and TideWM
-remain in their separate optional selections. Dolphin, Ark, Gwenview and
-Spectacle remain in `fiw-apps`.
-
-Repeat the audit on a device:
-
-```sh
-python3 tools/audit-packages.py
-python3 tools/audit-packages.py --json > local/package-audit.json
-```
-
-Version pins and old repository qualifiers are normalized for coverage;
-explicit Java slots remain distinct. Cyclic or missing selected sets are
-reported rather than treated as covered. The tool does not change package
-lists, Portage configuration or the device's world selections.
diff --git a/docs/scripts.md b/docs/scripts.md
index 93f5969..c893db3 100644
--- a/docs/scripts.md
+++ b/docs/scripts.md
@@ -1,38 +1,24 @@
-# Helpers and what they do
+# Commands and helpers
-| Helper | Purpose | How it is used |
-|---|---|---|
-| `install` | Opens the verified prebuilt chooser with a Go source fallback, or forwards CLI options to the Python backend | Main entry point |
-| `lib/launcher.py` | Verifies frontend/binary fingerprints and extracts/caches the bundled TUI | Called by the entry point |
-| `lib/rice.py` | Validates selections, previews plans, restores configs and installs Portage packages | Called by the entry point/TUI |
-| `lib/device_presets.py` | Saves and lists named local device selections with overwrite prompts | TUI named preset action or backend commands |
-| `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 |
-| `lib/backups.py` | Lists and previews user config backups, restores selected files and backs up their current versions | TUI backup action or backend commands |
-| `lib/proposals.py` | Lists pending config changes, previews diffs and accepts individual proposals after checking for newer edits | TUI pending updates action or backend commands |
-| `lib/reporting.py` | Correlates root/user steps, failures, skips and manual setup into one local summary | Runs after TUI apply actions; `--summary` for CLI use |
-| `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 selected configs or the explicit preference allowlist without copying full personal profiles | `--config` selects individual configs; 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 |
-| `tools/audit-packages.py` | Compares explicit world/selected sets with main/optional package lists and documented helper exceptions | Read-only package audit |
-| `tools/build-release.py` | Builds the generic static amd64 TUI bundle, fingerprints and dependency licence notices | Maintainer command before tagging snapshots |
-| `tools/ci/check.py` | Checks privacy, bundle/theme fingerprints and portable Python/Go behaviour | Forgejo and GitHub CI; can also run locally |
-| `tools/ci/mirror.py` | Copies Forgejo branches/tags to GitHub with a repository deploy key | Forgejo CI after checks; explicit manual sync supported |
-| `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 |
+| Command/helper | Purpose |
+|---|---|
+| `./install` | Opens the TUI; CLI options use the same backend. See [setup](setup.md). |
+| `tools/capture.py` | Refreshes selected config preferences from your home. See [capture](capture.md). |
+| `tools/shortcuts.py` | Regenerates the readable KDE shortcut reference after recapture. |
+| `tools/check-private.py` | Checks publishable files for personal paths, identifiers and credentials. |
+| `tools/build-release.py` | Rebuilds the bundled TUI, fingerprints and dependency licences. |
+| `tools/ci/check.py` | Checks privacy, asset fingerprints, Python syntax and frontend compilation. |
+| `tools/ci/mirror.py` | Synchronizes branches/tags to GitHub after checks. See [CI](ci.md). |
+| `fiw-shot` | Screenshot region/monitor, copy to clipboard, then offer Save/Edit. Installed with KDE shortcuts. |
+| `optional/fiw-cachy-patch` | Manually generates/installs a matching CachyOS kernel patch and updates the installed guard/name. |
+| `optional/spotify/spotify-patch` | Runs the optional Spotify customization workflow. |
+| `optional/spotify/spicetify-snapshotfix` | Supplies missing Marketplace fragments for that customization. |
-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 custom-kernel helper does not update the repo's `snapshot.json`, patch
+checksum or version mask. Review those together when refreshing the snapshot.
-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.
+Confirmed boot deployment installs its own refresh helper and kernel hooks.
+The default `keep` choice leaves them disabled. See [boot setup](boot.md).
-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.
+Spotify modifications are optional and at your own risk. This project does
+not endorse bypassing paid features. See [Spotify setup](spotify.md).
diff --git a/optional/fiw-limine-splice b/optional/fiw-limine-splice
deleted file mode 100755
index 38fc5df..0000000
--- a/optional/fiw-limine-splice
+++ /dev/null
@@ -1,54 +0,0 @@
-#!/usr/bin/env bash
-# fiw-limine-splice
-# Replaces the fiw-gentoo block in /limine.conf with the snippet Gentoo left
-# at //limine-entries.conf after a Gentoo kernel update.
-# Menu order (Fiw): Gentoo, CachyOS, other entries, Windows last.
-set -euo pipefail
-esp=${1:?usage: fiw-limine-splice }
-conf=$esp/limine.conf
-begin='# >>> fiw-gentoo (managed by fiw-limine, do not edit)'
-end='# <<< fiw-gentoo'
-
-shopt -s nullglob
-snippets=("$esp"/*/fiw-gentoo.marker)
-((${#snippets[@]} == 1)) || { echo "fiw-limine-splice: expected 1 Gentoo snippet, found ${#snippets[@]}" >&2; exit 1; }
-snippet=$(dirname "${snippets[0]}")/limine-entries.conf
-[[ -f $conf && -f $snippet ]] || exit 0
-
-tmp=$conf.fiw-tmp
-# Drop the old Gentoo block, split the rest into header + top-level entries
-# (a top-level entry starts at a line "/X" and runs to the next one), then
-# print: header, Gentoo block, CachyOS, other entries, Windows entries.
-awk -v b="$begin" -v e="$end" -v snip="$snippet" '
- BEGIN {n = 0}
- $0==b {skip=1} skip {if ($0==e) skip=0; next}
- /^comment: machine-id=/ {mid=$0; next}
- /^\/[^\/]/ {n++; if (mid != "") ent[n] = mid "\n"; mid=""}
- { ent[n] = ent[n] $0 "\n" }
- END {
- if (mid != "") ent[n] = ent[n] mid "\n"
- for (i = 1; i <= n; i++) {
- h = ent[i]; sub(/\n.*/, "", h)
- if (h ~ /^\/\+?CachyOS/) cls[i] = 1
- else if (ent[i] ~ /Windows|bootmgfw\.efi/) cls[i] = 3
- else cls[i] = 2
- }
- out = ent[0]
- sub(/\n+$/, "\n", out)
- out = out "\n" b "\n"
- while ((getline l < snip) > 0) out = out l "\n"
- out = out e "\n\n"
- for (c = 1; c <= 3; c++) for (i = 1; i <= n; i++) if (cls[i] == c) out = out ent[i]
- sub(/\n+$/, "\n", out)
- printf "%s", out
- }' "$conf" >"$tmp"
-
-# Refuse to write if any non-Gentoo line got lost.
-if ! diff <(awk -v b="$begin" -v e="$end" '$0==b{s=1} !s&&NF{print} $0==e{s=0}' "$conf" | sort) \
- <(awk -v b="$begin" -v e="$end" '$0==b{s=1} !s&&NF{print} $0==e{s=0}' "$tmp" | sort) >/dev/null; then
- rm -f "$tmp"; echo "fiw-limine-splice: reorder would change entries, leaving limine.conf alone" >&2; exit 1
-fi
-cmp -s "$tmp" "$conf" && { rm -f "$tmp"; exit 0; }
-cp -p "$conf" "$conf.before-fiw"
-mv "$tmp" "$conf"
-sync -f "$conf"
diff --git a/packages/audit-exceptions.json b/packages/audit-exceptions.json
deleted file mode 100644
index fd1aa0b..0000000
--- a/packages/audit-exceptions.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "app-admin/fiw-scripts": "Its two legacy kernel/boot helpers are retained as optional/fiw-cachy-patch and optional/fiw-limine-splice for manual use. The current boot action has its own refresh helper."
-}
diff --git a/tests/test_ci_mirror.py b/tests/test_ci_mirror.py
deleted file mode 100644
index e399d9b..0000000
--- a/tests/test_ci_mirror.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import importlib.util
-import subprocess
-import tempfile
-import unittest
-from pathlib import Path
-
-REPO = Path(__file__).resolve().parents[1]
-spec = importlib.util.spec_from_file_location('ci_mirror', REPO / 'tools/ci/mirror.py')
-ci_mirror = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(ci_mirror)
-
-
-def git(*args):
- return subprocess.check_output(['git', *map(str, args)], text=True, stderr=subprocess.DEVNULL).strip()
-
-
-def identity(path):
- for key, value in [('user.name', 'CI fixture'), ('user.email', 'ci-fixture'),
- ('commit.gpgsign', 'false'), ('tag.gpgsign', 'false')]:
- git('-C', path, 'config', key, value)
-
-
-class MirrorTests(unittest.TestCase):
- def test_mirror_preserves_tags_and_refs_and_refuses_divergence(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); work, source, target = root / 'work', root / 'source.git', root / 'target.git'
- git('init', '-b', 'main', work); identity(work)
- (work / 'README').write_text('first\n')
- git('-C', work, 'add', 'README'); git('-C', work, 'commit', '-m', 'first')
- git('-C', work, 'branch', 'other'); git('-C', work, 'tag', '-a', 'v1', '-m', 'version1')
- git('clone', '--bare', work, source); git('init', '--bare', target)
- head = git('-C', work, 'rev-parse', 'HEAD')
- ci_mirror.mirror(str(source), str(target), source_ref='refs/tags/v1', source_sha=head)
- self.assertEqual(git('-C', source, 'show-ref'), git('-C', target, 'show-ref'))
- git('-C', target, 'branch', 'github-only', head)
- (work / 'README').write_text('source update\n')
- git('-C', work, 'commit', '-am', 'source update'); git('-C', work, 'push', source, 'main')
- diverged = root / 'diverged'
- git('clone', '--branch', 'main', target, diverged); identity(diverged)
- (diverged / 'other').write_text('GitHub edit\n')
- git('-C', diverged, 'add', 'other'); git('-C', diverged, 'commit', '-m', 'GitHub edit')
- git('-C', diverged, 'push', 'origin', 'main')
- before = git('-C', target, 'show-ref')
- ci_mirror.mirror(str(source), str(target), source_ref='refs/heads/main', source_sha=head)
- self.assertEqual(before, git('-C', target, 'show-ref'))
- with self.assertRaises(subprocess.CalledProcessError):
- ci_mirror.mirror(str(source), str(target))
- self.assertEqual(before, git('-C', target, 'show-ref'))
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/test_configs.py b/tests/test_configs.py
deleted file mode 100644
index 08027cb..0000000
--- a/tests/test_configs.py
+++ /dev/null
@@ -1,108 +0,0 @@
-import importlib.util
-import json
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
-rice = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(rice)
-
-
-class ConfigTests(unittest.TestCase):
- def test_kconfig_preserves_unrelated_state_and_combines_window_rules(self):
- old = '# comment\n[General]\nrules=my-rule\ncount=1\nkeep=value\n[Session]\nprivate=preserved\n'
- desired = '[General]\nrules=fiw-dolphin-opacity\ncount=1\n[Style]\ncolour=purple\n'
- result = rice.merge_kconfig(old, desired)
- self.assertIn('# comment', result)
- self.assertIn('private=preserved', result)
- self.assertIn('keep=value', result)
- self.assertIn('rules=my-rule,fiw-dolphin-opacity', result)
- self.assertIn('count=2', result)
- self.assertEqual(rice.merge_kconfig(result, desired), result)
-
- def test_json_merge_preserves_account_and_unrelated_preferences(self):
- old = '{"session": "existing", "nested": {"unrelated": 1, "selected": 2}}'
- desired = '{"nested": {"selected": 3}}'
- self.assertEqual(json.loads(rice.merge_json(old, desired)),
- {'session': 'existing', 'nested': {'unrelated': 1, 'selected': 3}})
-
- def test_conflicts_backup_and_updates_preserve_local_edits(self):
- selection = rice.load_selection()
- selection['configs'] = ['fastfetch']
- with tempfile.TemporaryDirectory() as temp:
- home = Path(temp)
- target = home / '.config/fastfetch/config.jsonc'
- target.parent.mkdir(parents=True)
- target.write_text('original local config\n')
- kept = rice.apply_configs(selection, home, conflict='keep')
- self.assertEqual(target.read_text(), 'original local config\n')
- self.assertEqual(len(kept['kept']), 1)
- rice.apply_configs(selection, home, conflict='apply')
- backups = list((home / '.local/state/Fiw-Gentoo-Dots/backups').rglob('config.jsonc'))
- self.assertEqual(backups[0].read_text(), 'original local config\n')
- target.write_text('user edited config\n')
- rice.apply_configs(selection, home, update=True, conflict='apply')
- self.assertEqual(target.read_text(), 'user edited config\n')
- pending = Path(str(target) + '.new')
- self.assertEqual(json.loads(pending.read_text())['logo']['source'], 'gentoo')
- pending.write_text('user edited review\n')
- rice.apply_configs(selection, home, update=True, conflict='apply')
- self.assertEqual(pending.read_text(), 'user edited review\n')
-
- def test_shortcut_launchers_expand_target_home(self):
- selection = rice.load_selection()
- selection['configs'] = ['kde-shortcuts']
- with tempfile.TemporaryDirectory() as temp:
- home = Path(temp)
- rice.apply_configs(selection, home, conflict='apply')
- desktop = home / '.local/share/applications/net.local.fiw-shot-region.desktop'
- self.assertIn('Exec="' + str(home) + '/.local/bin/fiw-shot" region', desktop.read_text())
- self.assertNotIn('{{HOME}}', desktop.read_text())
- self.assertTrue((home / '.local/bin/fiw-shot').stat().st_mode & 0o111)
-
- def test_actual_kde_session_is_rejected_before_any_writes(self):
- selection = rice.load_selection()
- selection['configs'] = ['kde-style']
- with tempfile.TemporaryDirectory() as temp, patch.object(rice, 'active_plasma', return_value=True):
- home = Path(temp)
- with self.assertRaises(RuntimeError):
- rice.apply_configs(selection, home, conflict='apply')
- self.assertEqual(list(home.iterdir()), [])
-
- def test_source_plan_distinguishes_binary_payloads_and_dependencies(self):
- output = '[binary N ] app-misc/jq-1.8.2::gentoo\n[ebuild N ] dev-libs/foo-2.0::gentoo\n[ebuild N ] games-util/ge-proton-bin-11.7::fiw-dots\n[ebuild N ] kde-misc/apdatifier-gentoo-1.0.0::fiw-dots\n'
- self.assertEqual(rice.source_builds(output), {'dev-libs/foo-2.0'})
- self.assertEqual(rice.cp_from_cpv('dev-libs/foo-2.0'), 'dev-libs/foo')
-
- def test_configs_restore_without_installing_package_sections(self):
- selection = rice.load_selection()
- selection['groups'] = []
- selection['configs'] = ['fish', 'kitty', 'neovim', 'fastfetch']
- selection['extras'] = []
- self.assertEqual(rice.package_map(selection), {})
- with tempfile.TemporaryDirectory() as temp:
- home = Path(temp)
- report = rice.apply_configs(selection, home, conflict='apply')
- self.assertTrue(report['applied'])
- for relative in ['.config/fish/conf.d/fiw.fish', '.config/kitty/kitty.conf',
- '.config/nvim/init.lua', '.config/fastfetch/config.jsonc']:
- self.assertTrue((home / relative).is_file(), relative)
- self.assertNotIn('inactive', rice.preview(selection))
-
- def test_category_separation_and_stock_kernel(self):
- selection = rice.load_selection()
- packages = rice.package_map(selection)
- self.assertIn('sys-kernel/gentoo-kernel-bin', packages['system'])
- self.assertNotIn('sys-kernel/gentoo-kernel', packages['system'])
- self.assertFalse(any(p.startswith('=sys-kernel/gentoo-kernel-') for p in packages['system']))
- self.assertNotIn('kde-apps/dolphin', packages['kde'])
- self.assertIn('kde-apps/dolphin', packages['fiw-apps'])
- self.assertNotIn('kde-apps/filelight', packages['fiw-apps'])
- self.assertEqual(selection['bootloader'], 'keep')
- self.assertNotIn('autostart', selection['configs'])
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/test_devices_capture.py b/tests/test_devices_capture.py
deleted file mode 100644
index 8357fdc..0000000
--- a/tests/test_devices_capture.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import importlib.util
-import json
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-REPO = Path(__file__).resolve().parents[1]
-spec = importlib.util.spec_from_file_location('rice', REPO / 'lib/rice.py')
-rice = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(rice)
-import capture
-
-
-class DevicesAndCaptureTests(unittest.TestCase):
- def test_named_devices_preserve_independent_choices_and_ask_on_overwrite(self):
- with tempfile.TemporaryDirectory() as temp:
- repo = Path(temp)
- desktop = rice.load_selection(profile='fiw-ryzen')
- portable = rice.load_selection()
- portable.update(groups=['cli'], configs=['fish', 'neovim'], extras=[])
- rice.device_presets.save(repo, 'desktop', desktop, lambda *args: 'keep')
- rice.device_presets.save(repo, 'portable', portable, lambda *args: 'keep')
- original = rice.device_presets.path(repo, 'desktop').read_bytes()
- rice.device_presets.save(repo, 'desktop', portable, lambda *args: 'keep')
- self.assertEqual(rice.device_presets.path(repo, 'desktop').read_bytes(), original)
- devices = rice.device_presets.list_presets(repo, rice.load_selection)
- self.assertEqual(devices['desktop']['profile'], 'fiw-ryzen')
- self.assertEqual(devices['portable']['configs'], ['fish', 'neovim'])
- self.assertEqual(devices['portable']['name'], 'portable')
- self.assertEqual(rice.device_presets.path(repo, 'portable').stat().st_mode & 0o777, 0o600)
- rice.device_presets.save(repo, 'desktop', portable, lambda *args: 'save')
- self.assertEqual(rice.device_presets.list_presets(repo, rice.load_selection)['desktop']['profile'], 'stock')
-
- def test_unsafe_names_and_invalid_local_selections_do_not_enter_catalog(self):
- with tempfile.TemporaryDirectory() as temp:
- repo = Path(temp)
- root = repo / 'local/devices'; root.mkdir(parents=True)
- for content in ('[]', '{"profile":"stock"}', '{broken'):
- (root / 'broken.json').write_text(content)
- self.assertEqual(rice.device_presets.list_presets(repo, rice.load_selection), {})
- for name in ('../escape', '/escape', '-name', 'Desktop', 'a'*49):
- with self.assertRaises(ValueError):
- rice.device_presets.save(repo, name, rice.load_selection(), lambda *args: 'save')
- (root / 'linked.json').symlink_to(repo / 'outside.json')
- with self.assertRaises(RuntimeError):
- rice.device_presets.save(repo, 'linked', rice.load_selection(), lambda *args: 'save')
- self.assertFalse((repo / 'outside.json').exists())
-
- def test_selective_portable_capture_needs_no_other_app_or_kde_files(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); home, repo = root / 'home', root / 'repo'
- source = home / '.config/kitty/kitty.conf'; source.parent.mkdir(parents=True)
- source.write_text('foreground #abcdef\nlinux_display_server wayland\n')
- unrelated = repo / 'configs/fish/files/.config/fish/conf.d/fiw.fish'
- unrelated.parent.mkdir(parents=True); unrelated.write_text('keep existing capture\n')
- with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
- capture.capture(['kitty'])
- target = repo / 'configs/kitty/files/.config/kitty/kitty.conf'
- self.assertIn('linux_display_server auto', target.read_text())
- self.assertEqual(unrelated.read_text(), 'keep existing capture\n')
- self.assertEqual({path.relative_to(repo).parts[1] for path in repo.rglob('*') if path.is_file()}, {'kitty','fish'})
-
- def test_shortcuts_capture_keeps_style_and_drops_device_ids(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); home, repo = root / 'home', root / 'repo'
- source = home / '.config/kwinrc'; source.parent.mkdir(parents=True)
- source.write_text('[Desktops]\nNumber=1\nRows=1\nId_1=device-only\n[org.kde.kdecoration2]\ntheme=local\n')
- style = repo / 'configs/kde-style/kconfig/.config/kwinrc'
- style.parent.mkdir(parents=True); style.write_text('captured style stays intact\n')
- with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
- capture.capture(['kde-shortcuts'])
- result = (repo / 'configs/kde-shortcuts/kconfig/.config/kwinrc').read_text()
- self.assertIn('Number=1',result)
- self.assertNotIn('device-only',result)
- self.assertNotIn('theme=local',result)
- self.assertEqual(style.read_text(), 'captured style stays intact\n')
-
- def test_vscode_capture_reads_jsonc_and_omits_machine_settings(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); home, repo = root / 'home', root / 'repo'
- source = home / '.config/Code/User/settings.json'; source.parent.mkdir(parents=True)
- source.write_text('{ // comment\n"workbench.colorTheme":"ayu MiDas", /* comment */\n"claudeCode.selectedModel":"literal//comma,}",\n"java.configuration.runtimes":[{"path":"device-only"}], "unrelatedSecret":"keep-local",}')
- with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
- capture.capture(['vscode'])
- result = json.loads((repo / 'configs/vscode/json/.config/Code/User/settings.json').read_text())
- self.assertEqual(result, {'workbench.colorTheme':'ayu MiDas','claudeCode.selectedModel':'literal//comma,}'})
-
- def test_restore_then_recapture_keeps_helpers_portable_and_does_not_nest_guards(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); home, repo = root / 'home', root / 'repo'
- home.mkdir()
- selection = rice.load_selection()
- selection.update(groups=[], configs=['fish', 'kde-shortcuts'])
- rice.apply_configs(selection, home, conflict='apply')
- with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
- capture.capture(['fish', 'kde-shortcuts'])
- fish = (repo / 'configs/fish/files/.config/fish/conf.d/fiw.fish').read_text()
- self.assertEqual(fish.count('if type -q fiw-update'), 1)
- desktop = (repo / 'configs/kde-shortcuts/files/.local/share/applications/net.local.fiw-shot-region.desktop').read_text()
- self.assertIn('Exec="{{HOME}}/.local/bin/fiw-shot" region', desktop)
- self.assertNotIn(str(home), desktop)
-
- def test_vscode_restore_merges_jsonc_and_keeps_local_runtime_preferences(self):
- with tempfile.TemporaryDirectory() as temp:
- home = Path(temp)
- target = home / '.config/Code/User/settings.json'
- target.parent.mkdir(parents=True)
- target.write_text('{ // local preference comment\n"privatePreference":"local-only",\n"java.configuration.runtimes":[{"path":"device-only"}],}')
- selection = rice.load_selection(); selection.update(groups=[], configs=['vscode'])
- rice.apply_configs(selection, home, conflict='apply')
- result = json.loads(target.read_text())
- self.assertEqual(result['privatePreference'], 'local-only')
- self.assertEqual(result['java.configuration.runtimes'], [{'path':'device-only'}])
- self.assertEqual(result['workbench.colorTheme'], 'ayu MiDas')
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/test_proposals.py b/tests/test_proposals.py
deleted file mode 100644
index 9ce7e27..0000000
--- a/tests/test_proposals.py
+++ /dev/null
@@ -1,131 +0,0 @@
-import importlib.util
-import json
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
-rice = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(rice)
-
-
-class ProposalTests(unittest.TestCase):
- def create(self, home):
- selection = rice.load_selection()
- selection.update(groups=[], configs=['fastfetch'])
- rice.apply_configs(selection, home, conflict='apply')
- target = home / '.config/fastfetch/config.jsonc'
- target.write_text('local preferences\n')
- rice.apply_configs(selection, home, update=True, conflict='apply')
- candidate = Path(str(target) + '.new')
- return selection, target, candidate, candidate.relative_to(home).as_posix()
-
- def test_accept_backs_up_local_file_and_keeps_other_proposals(self):
- with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
- home = Path(temp)
- selection, target, candidate, proposal_id = self.create(home)
- proposed = candidate.read_bytes()
- rice.apply_configs(selection, home, update=True, conflict='apply')
- pending = rice.proposals.list_proposals(home, rice.backup_paths())
- self.assertEqual(len(pending), 2)
- self.assertIn('-local preferences', rice.proposals.preview(home, proposal_id, rice.backup_paths()))
- report = rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
- self.assertEqual(target.read_bytes(), proposed)
- backup = rice.backups.user_state(home) / 'backups' / report['backup'] / '.config/fastfetch/config.jsonc'
- self.assertEqual(backup.read_text(), 'local preferences\n')
- self.assertFalse(candidate.exists())
- pending = rice.proposals.list_proposals(home, rice.backup_paths())
- self.assertEqual(len(pending), 1)
- self.assertEqual(pending[0]['status'], 'unchanged')
- index = json.loads((rice.backups.user_state(home) / 'managed.json').read_text())
- self.assertEqual(index['.config/fastfetch/config.jsonc'], rice.digest(target))
-
- def test_keep_and_stale_targets_preserve_both_files(self):
- with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
- home = Path(temp)
- _, target, candidate, proposal_id = self.create(home)
- original_proposal = candidate.read_bytes()
- rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'keep', lambda *args: False)
- self.assertEqual(target.read_text(), 'local preferences\n')
- self.assertEqual(candidate.read_bytes(), original_proposal)
- target.write_text('newer local preferences\n')
- with self.assertRaisesRegex(RuntimeError, 'changed since'):
- rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
- self.assertEqual(target.read_text(), 'newer local preferences\n')
- self.assertEqual(candidate.read_bytes(), original_proposal)
-
- def test_target_change_during_confirmation_is_rejected(self):
- with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
- home = Path(temp)
- _, target, candidate, proposal_id = self.create(home)
- def confirm(*args):
- target.write_text('app rewrote its config\n')
- return 'apply'
- with self.assertRaisesRegex(RuntimeError, 'changed during'):
- rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
- self.assertEqual(target.read_text(), 'app rewrote its config\n')
- self.assertTrue(candidate.is_file())
-
- def test_manually_edited_proposal_remains_local_on_future_updates(self):
- with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
- home = Path(temp)
- selection, target, candidate, proposal_id = self.create(home)
- candidate.write_text('chosen custom config\n')
- rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
- rice.apply_configs(selection, home, update=True, conflict='apply')
- self.assertEqual(target.read_text(), 'chosen custom config\n')
- self.assertTrue(candidate.is_file())
-
- def test_proposal_change_during_confirmation_requires_another_review(self):
- with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
- home = Path(temp)
- _, target, candidate, proposal_id = self.create(home)
- def confirm(*args):
- candidate.write_text('edited proposal during review\n')
- return 'apply'
- with self.assertRaisesRegex(RuntimeError, 'proposal changed during'):
- rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
- self.assertEqual(target.read_text(), 'local preferences\n')
- self.assertEqual(candidate.read_text(), 'edited proposal during review\n')
-
- def test_paths_outside_home_and_live_kde_are_rejected(self):
- with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
- root = Path(temp); home = root / 'home'; home.mkdir()
- outside = root / 'outside'; outside.mkdir()
- _, target, candidate, proposal_id = self.create(home)
- config = home / '.config'
- config.rename(home / 'saved-config')
- config.symlink_to(outside, target_is_directory=True)
- with self.assertRaisesRegex(RuntimeError, 'Unsafe'):
- rice.proposals.list_proposals(home, rice.backup_paths())
- config.unlink(); (home / 'saved-config').rename(config)
- allowed = rice.backup_paths()
- allowed['.config/fastfetch/config.jsonc']['kde'] = True
- with self.assertRaisesRegex(RuntimeError, 'Log out'):
- rice.proposals.accept(home, proposal_id, allowed, lambda *args: 'apply', lambda *args: True)
- self.assertEqual(target.read_text(), 'local preferences\n')
- self.assertTrue(candidate.is_file())
-
- def test_shared_kconfig_generates_one_complete_proposal(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); home = root / 'home'; home.mkdir()
- relative = Path('.config/kwinrc')
- target = home / relative; target.parent.mkdir()
- target.write_text('[Appearance]\ncolour=orange\n[Desktops]\nNumber=4\n[Other]\nkeep=yes\n')
- style, shortcuts = root / 'style', root / 'shortcuts'
- style.write_text('[Appearance]\ncolour=purple\n')
- shortcuts.write_text('[Desktops]\nNumber=1\n')
- entries = [('kde-style', 'kconfig', style, relative), ('kde-shortcuts', 'kconfig', shortcuts, relative)]
- with patch.object(rice, 'config_entries', return_value=entries):
- rice.apply_configs(rice.load_selection(), home, update=True, conflict='apply')
- choices = rice.proposals.list_proposals(home, rice.backup_paths())
- self.assertEqual(len(choices), 1)
- content = (home / choices[0]['id']).read_text()
- for value in ('colour=purple', 'Number=1', 'keep=yes'):
- self.assertIn(value, content)
- self.assertIn('colour=orange', target.read_text())
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/test_release.py b/tests/test_release.py
deleted file mode 100644
index c68a6fc..0000000
--- a/tests/test_release.py
+++ /dev/null
@@ -1,81 +0,0 @@
-import gzip
-import importlib.util
-import json
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-REPO = Path(__file__).resolve().parents[1]
-
-
-def module(name, path):
- spec = importlib.util.spec_from_file_location(name, path)
- value = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(value)
- return value
-
-
-launcher = module('launcher', REPO / 'lib/launcher.py')
-audit = module('audit', REPO / 'tools/audit-packages.py')
-
-
-class ReleaseTests(unittest.TestCase):
- def bundle(self, repo):
- for name in ('go.mod', 'go.sum', 'VERSION'):
- (repo / name).write_text('fixture')
- root = repo / 'assets/bin'; root.mkdir(parents=True)
- data = b'fixture-binary'
- archive = gzip.compress(data, mtime=0)
- (root / 'fiw-dots.gz').write_bytes(archive)
- metadata = {'source_sha256': launcher.source_hash(repo), 'binaries': {
- 'linux-amd64': {'file': 'fiw-dots.gz', 'size': len(data),
- 'sha256': launcher.digest(data), 'gzip_sha256': launcher.digest(archive)}}}
- (root / 'tui.json').write_text(json.dumps(metadata))
- return data
-
- def test_bundle_runs_without_go_and_replaces_a_damaged_cache(self):
- with tempfile.TemporaryDirectory() as temp:
- repo = Path(temp); data = self.bundle(repo)
- cache = repo / 'build/fiw-dots'; cache.parent.mkdir(); cache.write_bytes(b'damaged')
- with patch.object(launcher.platform, 'system', return_value='Linux'), patch.object(launcher.platform, 'machine', return_value='x86_64'), patch.object(launcher.shutil, 'which', return_value=None), patch.object(launcher.os, 'chdir'), patch.dict(launcher.os.environ, {'FIW_DOTS_SOURCE': '0'}), patch.object(launcher.subprocess, 'run') as run:
- self.assertEqual(launcher.launch(repo), cache)
- self.assertEqual(cache.read_bytes(), data)
- self.assertTrue(cache.stat().st_mode & 0o111)
- run.assert_not_called()
-
- def test_changed_frontend_and_modified_archives_are_rejected(self):
- with tempfile.TemporaryDirectory() as temp:
- repo = Path(temp); self.bundle(repo)
- cache = repo / 'build/fiw-dots'
- with patch.object(launcher.platform, 'system', return_value='Linux'), patch.object(launcher.platform, 'machine', return_value='x86_64'):
- archive = repo / 'assets/bin/fiw-dots.gz'
- original = archive.read_bytes(); archive.write_bytes(original + b'modified')
- with self.assertRaises(RuntimeError):
- launcher.bundled(repo, cache)
- archive.write_bytes(original)
- (repo / 'cmd/dots').mkdir(parents=True)
- (repo / 'cmd/dots/main.go').write_text('changed frontend')
- with self.assertRaises(RuntimeError):
- launcher.bundled(repo, cache)
- self.assertFalse(cache.exists())
-
- def test_audit_expands_only_selected_sets_and_preserves_java_slots(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); sets = root / 'sets'; sets.mkdir()
- world, selected = root / 'world', root / 'world_sets'
- world.write_text('=app-editors/vscode-1.0::old\n')
- selected.write_text('@desktop\n')
- (sets / 'desktop').write_text('app-editors/vscode\n@java\n')
- (sets / 'java').write_text('dev-java/openjdk-bin:21\n')
- (sets / 'not-selected').write_text('dev-libs/dependency-only\n')
- result, unresolved = audit.explicit(world, selected, sets)
- self.assertEqual(set(result), {'app-editors/vscode', 'dev-java/openjdk-bin:21'})
- self.assertEqual(unresolved, [])
- (sets / 'java').write_text('@desktop\n')
- with self.assertRaises(ValueError):
- audit.explicit(world, selected, sets)
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/test_restoration.py b/tests/test_restoration.py
deleted file mode 100644
index f745596..0000000
--- a/tests/test_restoration.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import importlib.util
-import json
-import subprocess
-import tempfile
-import unittest
-from pathlib import Path
-from unittest.mock import patch
-
-spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
-rice = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(rice)
-
-
-class RestorationTests(unittest.TestCase):
- def test_app_capture_uses_explicit_preferences_and_omits_accounts_and_paths(self):
- import capture
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- home, repo = root / 'home', root / 'repo'
- source = home / '.local/share/PrismLauncher/prismlauncher.cfg'
- source.parent.mkdir(parents=True)
- source.write_text('[General]\nApplicationTheme=Breeze\nIconTheme=breeze_dark\nSelectedInstance=local-instance\nJavaPath=/device/java\nModrinthToken=local-only\n[Accounts]\nuser=local-user\n')
- with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo):
- capture.capture_apps()
- result = (repo / 'configs/prism/kconfig/.local/share/PrismLauncher/prismlauncher.cfg').read_text()
- self.assertEqual(result, '[General]\nApplicationTheme=Breeze\nIconTheme=breeze_dark\n')
-
- def test_config_requirements_do_not_select_packages_and_report_actual_missing_apps(self):
- selection = rice.load_selection()
- selection.update(groups=[], configs=['kitty'], extras=[], bootloader='keep')
- with patch.object(rice.shutil, 'which', return_value='portageq'), patch.object(rice.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1)):
- requirements = rice.config_requirements(selection)
- self.assertEqual(requirements['kitty'], [{'package': 'x11-terms/kitty', 'status': 'missing'}])
- self.assertEqual(rice.package_map(selection), {})
- selection['groups'] = ['fiw-apps']
- self.assertEqual(rice.config_requirements(selection)['kitty'][0]['status'], 'selected')
- self.assertEqual(rice.config_requirements(selection, planned=False)['kitty'][0]['status'], 'missing')
-
- def test_backup_restore_preserves_current_file_and_future_updates_preserve_restored_edits(self):
- selection = rice.load_selection()
- selection.update(groups=[], configs=['fastfetch'])
- with tempfile.TemporaryDirectory() as temp, patch.object(rice.backups.os, 'geteuid', return_value=1000):
- home = Path(temp)
- target = home / '.config/fastfetch/config.jsonc'
- target.parent.mkdir(parents=True)
- target.write_text('original personal config\n')
- rice.apply_configs(selection, home, conflict='apply')
- current = target.read_text()
- choices = rice.backups.list_backups(home, rice.backup_paths())
- self.assertEqual(len(choices), 1)
- report = rice.backups.restore(home, choices[0]['id'], rice.backup_paths(), lambda *args: 'restore', lambda *args: False, conflict='apply')
- self.assertEqual(target.read_text(), 'original personal config\n')
- saved = rice.backups.user_state(home) / 'backups' / report['current_backup'] / '.config/fastfetch/config.jsonc'
- self.assertEqual(saved.read_text(), current)
- rice.apply_configs(selection, home, update=True, conflict='apply')
- self.assertEqual(target.read_text(), 'original personal config\n')
- self.assertTrue(Path(str(target) + '.new').is_file())
-
- def test_backup_preview_rejects_unknown_paths_and_destinations_outside_home(self):
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- home, outside = root / 'home', root / 'outside'
- home.mkdir(); outside.mkdir()
- backup_id = '20260101T000000.000000Z'
- backup = rice.backups.user_state(home) / 'backups' / backup_id
- source = backup / '.config/fastfetch/config.jsonc'
- source.parent.mkdir(parents=True); source.write_text('previous')
- (home / '.config').symlink_to(outside, target_is_directory=True)
- with self.assertRaises(RuntimeError):
- rice.backups.plan(home, backup_id, rice.backup_paths())
- (home / '.config').unlink()
- (backup / 'unrecognized').write_text('unrecognized')
- with self.assertRaises(RuntimeError):
- rice.backups.plan(home, backup_id, rice.backup_paths())
- self.assertEqual(list(outside.iterdir()), [])
-
- def test_shared_config_backup_keeps_original_before_all_selected_patches(self):
- selection = rice.load_selection()
- selection['configs'] = ['kde-style', 'kde-shortcuts']
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); home = root / 'home'; home.mkdir()
- relative = Path('.config/kwinrc')
- target = home / relative; target.parent.mkdir()
- original = '[Appearance]\ncolour=orange\n[Desktops]\nNumber=4\n'
- target.write_text(original)
- style, shortcuts = root / 'style', root / 'shortcuts'
- style.write_text('[Appearance]\ncolour=purple\n')
- shortcuts.write_text('[Desktops]\nNumber=1\n')
- entries = [('kde-style', 'kconfig', style, relative), ('kde-shortcuts', 'kconfig', shortcuts, relative)]
- with patch.object(rice, 'config_entries', return_value=entries):
- rice.apply_configs(selection, home, conflict='apply')
- saved = list((rice.backups.user_state(home) / 'backups').rglob('kwinrc'))
- self.assertEqual(len(saved), 1)
- self.assertEqual(saved[0].read_text(), original)
- self.assertIn('colour=purple', target.read_text())
- self.assertIn('Number=1', target.read_text())
-
- def test_combined_report_correlates_scopes_and_excludes_unrelated_runs(self):
- selection = rice.load_selection()
- selection.update(groups=['gaming'], configs=['fastfetch'], flatpaks=['org.vinegarhq.Sober'], services=['audio'])
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp); home = root / 'home'; home.mkdir()
- with patch.object(rice.reporting, 'SYSTEM_STATE', root / 'system'):
- with patch.object(rice.reporting.os, 'geteuid', return_value=0):
- rice.reporting.record(selection, 'current', 'packages', home, {'present': ['app-misc/fastfetch'], 'skipped': ['games-util/example'], 'missing': ['games-util/example']})
- with patch.object(rice.reporting.os, 'geteuid', return_value=1000):
- rice.reporting.record(selection, 'current', 'flatpaks', home, {'failed': ['org.vinegarhq.Sober']}, RuntimeError('download failed'))
- rice.reporting.record(selection, 'different', 'configs', home, {'applied': ['unrelated-file']})
- result = rice.reporting.summarize(selection, home, {}, ['manual reminder'], 'current')
- self.assertEqual(result['steps']['packages']['status'], 'partial')
- self.assertEqual(result['steps']['flatpaks']['status'], 'failed')
- self.assertEqual(result['steps']['configs']['status'], 'not attempted')
- self.assertEqual(result['steps']['services-user']['status'], 'not attempted')
- self.assertNotIn('unrelated-file', json.dumps(result))
- self.assertEqual(result['manual_steps'], ['manual reminder'])
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/test_setup.py b/tests/test_setup.py
deleted file mode 100644
index b2de262..0000000
--- a/tests/test_setup.py
+++ /dev/null
@@ -1,143 +0,0 @@
-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': ['tidewm']}), ['guru'])
- 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()
diff --git a/tools/audit-packages.py b/tools/audit-packages.py
deleted file mode 100644
index 9b7a307..0000000
--- a/tools/audit-packages.py
+++ /dev/null
@@ -1,92 +0,0 @@
-#!/usr/bin/env python3
-"""Compare explicit world/set selections with main and optional package lists."""
-import argparse
-import json
-import re
-from pathlib import Path
-
-REPO = Path(__file__).resolve().parents[1]
-
-
-def lines(path):
- if not path.is_file():
- return []
- return [line.split('#', 1)[0].strip() for line in path.read_text().splitlines()
- if line.split('#', 1)[0].strip()]
-
-
-def package(atom):
- value = atom.split('::', 1)[0].split('[', 1)[0].lstrip('<>=~')
- base, _, slot = value.partition(':')
- base = re.sub(r'-[0-9].*$', '', base)
- if not re.fullmatch(r'[A-Za-z0-9+_.-]+/[A-Za-z0-9+_.-]+', base):
- raise ValueError('Invalid explicit package atom.')
- return base + (':' + slot if slot else '')
-
-
-def explicit(world, world_sets, sets):
- selections, unresolved = {}, set()
- def read(items, origin, stack=()):
- for atom in items:
- if atom.startswith('@'):
- name = atom[1:]
- if not re.fullmatch(r'[A-Za-z0-9+_.-]+', name):
- raise ValueError('Invalid selected set name.')
- if name in stack:
- raise ValueError('Cyclic selected set: ' + name)
- source = sets / name
- if not source.is_file():
- unresolved.add(name)
- else:
- read(lines(source), '@' + name, stack + (name,))
- else:
- selections.setdefault(package(atom), set()).add(origin)
- read(lines(world), 'world')
- read(lines(world_sets), 'world_sets')
- return {atom: sorted(origins) for atom, origins in sorted(selections.items())}, sorted(unresolved)
-
-
-def audit(world, world_sets, sets, repo=REPO):
- selected, unresolved = explicit(world, world_sets, sets)
- coverage = {}
- for path in sorted((repo / 'packages').rglob('*.list')):
- if path.name == 'source.list':
- continue
- category = path.relative_to(repo / 'packages').with_suffix('').as_posix()
- for atom in lines(path):
- coverage.setdefault(package(atom), set()).add(category)
- for atom, category in [('sys-kernel/gentoo-kernel', 'kernel/custom'),
- ('sys-kernel/gentoo-kernel-bin', 'kernel/binary')]:
- coverage.setdefault(atom, set()).add(category)
- exceptions = json.loads((repo / 'packages/audit-exceptions.json').read_text())
- matched = {atom: sorted(coverage[atom]) for atom in selected if atom in coverage}
- manual = {atom: exceptions[atom] for atom in selected if atom in exceptions and atom not in coverage}
- missing = sorted(set(selected) - set(matched) - set(manual))
- return {'explicit_count': len(selected), 'covered_count': len(matched),
- 'coverage': matched, 'sources': selected, 'manual_helpers': manual,
- 'missing': missing, 'unresolved_sets': unresolved}
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--world', type=Path, default=Path('/var/lib/portage/world'))
- parser.add_argument('--world-sets', type=Path, default=Path('/var/lib/portage/world_sets'))
- parser.add_argument('--sets', type=Path, default=Path('/etc/portage/sets'))
- parser.add_argument('--json', action='store_true')
- args = parser.parse_args()
- if not args.world.is_file() and not args.world_sets.is_file():
- parser.error('No Portage explicit selection files found.')
- result = audit(args.world, args.world_sets, args.sets)
- if args.json:
- print(json.dumps(result, indent=2))
- else:
- print(str(result['covered_count']) + '/' + str(result['explicit_count']) + ' explicit selections covered by package lists/kernel choices.')
- for atom, reason in result['manual_helpers'].items():
- print('Manual helper: ' + atom + ' — ' + reason)
- print('Missing main packages: ' + (', '.join(result['missing']) or 'none'))
- print('Unresolved selected sets: ' + (', '.join(result['unresolved_sets']) or 'none'))
- return 1 if result['missing'] or result['unresolved_sets'] else 0
-
-
-if __name__ == '__main__':
- raise SystemExit(main())
diff --git a/tools/ci/check.py b/tools/ci/check.py
index 082b275..b6b7c4e 100644
--- a/tools/ci/check.py
+++ b/tools/ci/check.py
@@ -5,6 +5,7 @@ import hashlib
import importlib.util
import io
import json
+import os
import subprocess
from pathlib import Path
@@ -56,8 +57,11 @@ def check_assets():
def main():
subprocess.run(['python3', 'tools/check-private.py'], cwd=REPO, check=True)
check_assets()
- subprocess.run(['python3', '-m', 'unittest', 'discover', '-s', 'tests'], cwd=REPO, check=True)
- subprocess.run(['go', 'test', './...'], cwd=REPO, check=True)
+ for directory in ('lib', 'tools'):
+ for path in (REPO / directory).rglob('*.py'):
+ compile(path.read_bytes(), path.relative_to(REPO).as_posix(), 'exec')
+ print('Python sources parse.', flush=True)
+ subprocess.run(['go', 'build', '-o', os.devnull, './cmd/dots'], cwd=REPO, check=True)
print('Portable CI checks passed; no installer actions were applied.', flush=True)