Compare commits

..

No commits in common. "main" and "v0.1.2" have entirely different histories.
main ... v0.1.2

47 changed files with 5127 additions and 5120 deletions

View file

@ -1,50 +0,0 @@
name: Check and mirror to GitHub
on:
push:
pull_request:
workflow_dispatch:
jobs:
checks:
runs-on: ${{ vars.CI_RUNNER_LABEL || 'docker' }}
container: node:22-bookworm
timeout-minutes: 20
steps:
- name: Prepare CI tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 git ca-certificates
- name: Checkout
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Go
uses: https://github.com/actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.26.7'
cache: false
- name: Check code and bundled assets
run: python3 tools/ci/check.py
github-mirror:
needs: checks
if: github.event_name != 'pull_request' && github.repository == 'fiwdev/Fiw-Gentoo-Dots'
runs-on: ${{ vars.CI_RUNNER_LABEL || 'docker' }}
container: node:22-bookworm
timeout-minutes: 10
steps:
- name: Prepare mirror tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 git openssh-client ca-certificates
- name: Checkout
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Publish branches and tags to GitHub
env:
GH_MIRROR_SSH_KEY: ${{ secrets.GH_MIRROR_SSH_KEY }}
MIRROR_SOURCE_REF: ${{ github.ref }}
MIRROR_SOURCE_SHA: ${{ github.sha }}
run: python3 tools/ci/mirror.py

View file

@ -1,31 +0,0 @@
name: Checks
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-latest
container: node:22-bookworm
timeout-minutes: 20
steps:
- name: Prepare CI tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 git ca-certificates
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.26.7'
cache: false
- name: Check code and bundled assets
run: python3 tools/ci/check.py

189
README.md
View file

@ -9,111 +9,174 @@
![Gentoo](https://img.shields.io/badge/Gentoo-54487A?logo=gentoo&logoColor=white) ![Gentoo](https://img.shields.io/badge/Gentoo-54487A?logo=gentoo&logoColor=white)
![KDE Plasma](https://img.shields.io/badge/KDE_Plasma-1D99F3?logo=kde&logoColor=white) ![KDE Plasma](https://img.shields.io/badge/KDE_Plasma-1D99F3?logo=kde&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-purple) ![License](https://img.shields.io/badge/license-MIT-purple)
![Status](https://img.shields.io/badge/status-work_in_progress-yellow)
[Setup](docs/setup.md) · [Shortcuts](docs/shortcuts.md) · [Styling](docs/kde-style.md) · [Packages](#packages) [KDE styling](docs/kde-style.md) · [Shortcuts](docs/shortcuts.md) · [Packages](#package-sections) · [Setup](docs/setup.md)
</div> </div>
## About ---
My personal Gentoo restoration kit, mainly for my own devices: ## Overview
- KDE colours, fonts, cursor, window styling and my exact keybinds. A personal Gentoo rice and restoration kit: my Plasma styling, current
- Separate package lists and individually optional app configs. shortcuts, package lists and the app preferences I use every day. Built
- Fish, Kitty and Neovim preferences that work across compositors. mainly for my own devices, with deliberate updates and a preview before
- Binaries preferred, with a small deliberate source-build list. anything is applied.
- A Bubble Tea chooser with a final preview, conflict prompts and backups.
Stock and Fiw's Ryzen have been installed and checked in Gentoo QEMU/KVM. - **KDE first:** colours, fonts, cursor, window styling and current keybinds.
The full TUI restore, KDE presets, optional apps, GRUB/Limine and ext4/Btrfs - **Pick what you want:** package sections and individually optional app configs.
booting passed. See [tested scope and hardware limits](docs/setup.md#current-scope). - **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.
## Screenshots **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.
### Desktop The existing installation has not been changed by creating this repo.
![Fiw's Gentoo desktop](assets/Screenshots/Main-screen.png)
My running desktop. Wallpapers and panels stay local; the saved Fastfetch
preset uses the built-in Gentoo logo.
### Installer
![Terminal installer options](assets/Screenshots/Install-script.png)
## Start ## Start
```sh ```sh
git clone https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git git clone --branch v0.1.2 https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
cd Fiw-Gentoo-Dots cd Fiw-Gentoo-Dots
./install ./install
``` ```
Start from an installed Gentoo system. Linux amd64 includes a prebuilt TUI; The Bubble Tea interface offers a preset, package sections, individual app
Git and Python 3.11+ are enough. Go 1.24+ is needed only for source builds. configs, kernel/bootloader choices, individual Flatpaks, optional services and
Choose your packages/configs, review the plan, then select an action. a final preview. Enter saves without applying. `r` runs the full restore;
Enter saves your choices without applying them. `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).
See [setup and TUI keys](docs/setup.md), [tagged snapshots](docs/releases.md), For a preview without building the TUI:
[saved devices](docs/devices.md), [config updates](docs/updates.md) and
[backup restoration](docs/backups.md). ```sh
./install --plan --profile stock
./install --plan --profile fiw-ryzen
```
See [setup](docs/setup.md) for installation steps and draft limitations.
## Presets ## Presets
| Preset | Build settings | Kernel | | Preset | Build settings | Kernel |
|---|---|---| |---|---|---|
| Stock | Portable settings, binaries preferred | Generic Gentoo binary kernel | | Stock | Portable compiler settings; binary packages preferred | Generic Gentoo binary kernel |
| Fiw's Ryzen | Ryzen 9900X / Zen 5 tuning and NVIDIA | Custom kernel + binary fallback | | Fiw's Ryzen | Current Ryzen 9900X/Zen 5 tuning and NVIDIA configuration | Tested custom kernel plus generic binary fallback |
Both compile Fastfetch, jq and zip. Selected custom kernels and modules also Both deliberately compile Fastfetch, jq and zip. Custom kernels and their
compile. Other source builds require a compile-or-skip choice; missing/skipped matching external modules compile when selected. Other packages prefer
packages appear in the [final report](docs/reports.md). 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.
## Packages 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
| List | Contents | | List | Contents |
|---|---| |---|---|
| [kde](packages/kde.list) | Plasma, Plasma Login Manager and desktop components | | [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 | | [fiw-apps](packages/fiw-apps.list) | Everyday apps including Dolphin, Ark, Gwenview and Spectacle |
| [cli](packages/cli.list) | Shell and terminal utilities | | [cli](packages/cli.list) | Shell, terminal utilities and FFmpeg |
| [dev](packages/dev.list) | Languages and development tools | | [dev](packages/dev.list) | Languages and development tools |
| [gaming](packages/gaming.list) | Steam, Proton, Prism, MangoHud and r2modman | | [gaming](packages/gaming.list) | Steam, Proton, Prism, MangoHud and r2modman; Sober is in flatpaks/gaming.list |
| [system](packages/system.list) | System utilities; kernels follow your selection | | [system](packages/system.list) | System utilities; the selected kernels are added by the installer |
| [fiw-tools](packages/fiw-tools.list) | FiwNode, Apdatifier Gentoo, OpenDeck and Music Presence | | [fiw-tools](packages/fiw-tools.list) | FiwNode binary release, Apdatifier Gentoo, OpenDeck and Music Presence |
| [tidewm](packages/tidewm.list) | Optional compositor and supporting tools | | [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities |
Filelight, NVIDIA and Btrfs tools are separate extras. Sober and LocalSend are Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not
individually optional Flatpaks. Limine and GRUB are optional; keeping the require Btrfs tools. LocalSend and Sober are individually optional Flatpaks
existing bootloader is the default. See [boot setup](docs/boot.md). 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.
## Configs ## Configs
Every [app config](docs/app-configs.md) is independently optional. KDE styling Every config is independently selectable, even when its package section is
includes matching GTK preferences; autostart has its own checkbox. unchecked—for example, to configure apps already installed. KDE appearance covers
Neovim's Catppuccin theme, VS Code's ayu MiDas theme, fonts and cursor assets colours, fonts, cursor and window decorations, with matching GTK preferences.
are bundled with their licences. Accounts and sessions stay local. 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.
Local edits are preserved on updates, with proposed changes saved as `.new` Fish, Kitty and Neovim are usable without KDE or KWin. Fastfetch keeps the
for review. Replaced files get backups. Spotify stays stock; tree layout and purple colours with the built-in Gentoo logo. MangoHud and
[customization](docs/spotify.md) and [Firefox-Privacy](docs/firefox.md) are Vesktop preferences are included; browser profiles, chat sessions and account
explicit optional steps. 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
```
## Maintenance ## Maintenance
```sh ```sh
python3 tools/capture.py --config kde-shortcuts python3 tools/capture.py # recapture only the explicit allowlist
python3 tools/capture.py --config kitty neovim python3 tools/capture.py --config kde-shortcuts # refresh only shortcuts
python3 tools/capture.py --config kitty neovim # refresh only these apps
python3 tools/check-private.py python3 tools/check-private.py
python3 -m unittest discover -s tests
go test ./...
``` ```
Review `git diff` before committing. See [capture options](docs/capture.md) Missing selected overlays are staged before package resolution. Optional
and [helper commands](docs/scripts.md). Device choices and local files are service presets enable audio, networking, Bluetooth and power profiles for
gitignored. Forgejo is primary; [GitHub](https://github.com/Fi3w0/Fiw-Gentoo-Dots) the next boot/login. See [setup](docs/setup.md) and the [helper list](docs/scripts.md).
receives branches and tags automatically after [CI checks](docs/ci.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.
## License ## License
Original code and configs: [MIT](LICENSE). Bundled third-party assets keep Original code and configuration contributions are licensed under [MIT](LICENSE).
their own licences; see [credits](docs/assets.md). Bundled third-party assets, fonts, patches and ebuilds keep their own licences
and attribution; see [asset credits](docs/assets.md).
---
<div align="center">A personal Gentoo setup by Fiw.</div> <div align="center">A personal Gentoo setup by Fiw.</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

207
cmd/dots/main_test.go Normal file
View file

@ -0,0 +1,207 @@
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")
}
}

View file

@ -61,10 +61,9 @@
"label": "Kitty lavender profiles", "label": "Kitty lavender profiles",
"group": "fiw-apps", "group": "fiw-apps",
"packages": [ "packages": [
"x11-terms/kitty", "x11-terms/kitty"
"app-shells/fish"
], ],
"note": "Both profiles start Fish. Select fonts separately for the bundled JetBrainsMono Nerd Font." "note": "Select fonts separately for the bundled JetBrainsMono Nerd Font."
}, },
"neovim": { "neovim": {
"label": "Neovim transparent purple style", "label": "Neovim transparent purple style",

View file

@ -5,7 +5,6 @@ italic_font auto
bold_italic_font auto bold_italic_font auto
font_size 11.0 font_size 11.0
shell fish
linux_display_server auto linux_display_server auto
background_blur 32 background_blur 32
dynamic_background_opacity yes dynamic_background_opacity yes

View file

@ -43,10 +43,6 @@ packages again, including requirements of apps whose installation was skipped.
Fish, Kitty, Neovim and Fastfetch have no Plasma or KWin requirements. Fonts Fish, Kitty, Neovim and Fastfetch have no Plasma or KWin requirements. Fonts
have their own checkbox; Kitty can use its fallback font if those are omitted. have their own checkbox; Kitty can use its fallback font if those are omitted.
Both Kitty profiles start Fish explicitly, so the Kitty preset requires Fish
even when the optional Fish preferences are unchecked.
For Fish in TTY and SSH logins too, run `chsh -s "$(command -v fish)"` as your
regular user after installing Fish, then log in again.
Neovim's config includes a pinned Catppuccin runtime in its native package Neovim's config includes a pinned Catppuccin runtime in its native package
directory and loads it before the captured purple highlights. It needs no directory and loads it before the captured purple highlights. It needs no
plugin manager or runtime download. A built-in fallback remains available if plugin manager or runtime download. A built-in fallback remains available if

View file

@ -13,9 +13,8 @@ sudo ./install --selection local/selection.json --deploy-bootloader
sudo ./install --selection local/selection.json --deploy-bootloader --esp /boot/efi sudo ./install --selection local/selection.json --deploy-bootloader --esp /boot/efi
``` ```
Discovery checks `/efi`, `/boot/efi`, `/boot` and FAT mount points from Discovery checks mounted `/efi`, `/boot/efi` and `/boot` candidates for the GPT
`/etc/fstab` for the GPT ESP type and FAT filesystem. An `x-systemd.automount` ESP type and FAT filesystem. The plan shows the selected partition, root
ESP is mounted by accessing it first. The plan shows the selected partition, root
filesystem, kernel arguments, installed kernel/initramfs candidates and filesystem, kernel arguments, installed kernel/initramfs candidates and
proposed loader config or commands. UUIDs are derived on the target and kept proposed loader config or commands. UUIDs are derived on the target and kept
in its local state; they are not shipped in the repo. in its local state; they are not shipped in the repo.
@ -42,8 +41,7 @@ followed by generic kernels, including an installed binary fallback.
A marked block contains the generated Gentoo entries. Entries outside that A marked block contains the generated Gentoo entries. Entries outside that
block are preserved. Locally edited managed entries receive a `.new` proposal block are preserved. Locally edited managed entries receive a `.new` proposal
instead of being overwritten. ESP free space is checked before copying kernel instead of being overwritten. ESP free space is checked before copying kernel
artifacts. Once the managed menu is current, kernel folders it no longer lists artifacts. The protocol and paths follow the upstream
are removed from the ESP; folders with other files are left alone. The protocol and paths follow the upstream
[Limine configuration reference](https://github.com/limine-bootloader/limine/blob/v12.x/CONFIG.md). [Limine configuration reference](https://github.com/limine-bootloader/limine/blob/v12.x/CONFIG.md).
## GRUB ## GRUB
@ -57,11 +55,6 @@ is generated before replacing it. If the previous menu was edited locally,
the new menu becomes a `.new` proposal. Existing `/etc/default/grub` settings the new menu becomes a `.new` proposal. Existing `/etc/default/grub` settings
are used by `grub-mkconfig`. are used by `grub-mkconfig`.
Explicit deployment asks before replacing a conflicting GRUB menu or Limine
managed block, with a backup. Keeping it writes a proposal and leaves firmware
registration and order unchanged. Automatic kernel refreshes preserve edits
without prompting; review their proposals before using the new entries.
## Kernel updates and backups ## Kernel updates and backups
Deployment installs a standalone refresh helper at Deployment installs a standalone refresh helper at
@ -79,11 +72,13 @@ live under `/var/lib/Fiw-Gentoo-Dots`. No existing disk partition is formatted.
Automatic deployment currently supports amd64 UEFI, GPT ESPs, plain ext4/Btrfs Automatic deployment currently supports amd64 UEFI, GPT ESPs, plain ext4/Btrfs
roots and unsigned boot. Encrypted, UKI-only, BIOS and signing workflows can roots and unsigned boot. Encrypted, UKI-only, BIOS and signing workflows can
keep their existing loader. Secure Boot being active stops unsigned deployment keep their existing loader. Secure Boot being active stops unsigned deployment
before changes. Automatic GRUB and Limine deployment and booting were checked before changes. Full boot execution on a fresh installation remains unverified.
in Gentoo QEMU/KVM with ext4 and a Btrfs `@` root. The custom Ryzen kernel and
binary fallback booted, and addition/removal hooks and preservation of edited ## Retained helpers
menus were checked. Physical firmware and unsupported storage layouts still
need their own setup. `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 `optional/fiw-cachy-patch` refreshes the custom kernel patch; it does not select
a bootloader. See the [helper list](scripts.md) before using it. a bootloader. See the [helper list](scripts.md) before using legacy helpers.

View file

@ -1,63 +0,0 @@
# CI and GitHub synchronization
Push changes to the primary
[Forgejo repository](https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots).
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 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).
Pull request runs perform checks without mirroring. GitHub's separate workflow
runs the same checks on received pushes and pull requests.
The runner uses a Debian container with Python 3.11 and pinned Go 1.26.7.
Remote checkout/setup actions use fixed commit IDs. CI checks the installer
code without installing Gentoo packages, applying live desktop configs or
deploying bootloaders. It verifies the existing release bundle; changing
frontend source requires rebuilding and committing its matching bundle.
## Forgejo setup
Repository Actions must be enabled and an available runner must match
`docker`. If your runner has another label, set the Forgejo repository
Actions variable `CI_RUNNER_LABEL` to that label. The runner needs Docker to
execute the workflow's container jobs. See the
[Forgejo 10 Actions guide](https://forgejo.org/docs/v10.0/user/actions/) for
runner and secret settings used by this instance.
Add the repository Actions secret **`GH_MIRROR_SSH_KEY`** with the complete
private SSH deploy key registered on the GitHub mirror. The initial key is
stored locally at `local/ci/github-mirror`, outside version control. The
corresponding GitHub deploy key is named **Forgejo mirror**, with write access.
GitHub documents [deploy key management](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys).
The workflow exposes the key only to the mirroring step and uses a temporary
mode-600 key file, removed when the process exits. GitHub's Ed25519 host key
is pinned in `tools/ci/github_known_hosts` from its authenticated metadata API.
The **Run workflow** action retries synchronization after adding a secret or
runner. Pushing another commit also triggers it.
## Sync behaviour
The mirror fetches the current source repository and checks that the triggering
ref still points to the commit reviewed by that CI run. Superseded runs skip
publication. Branches and annotated tags keep their original history and IDs.
Pushes are atomic and use ordinary fast-forward rules. Divergent GitHub edits
cause an error for review. GitHub-only refs and refs deleted at the source are
retained. Keep normal development on Forgejo to maintain a consistent mirror.
For an explicit manual sync with the initial local deploy key:
```sh
python3 tools/ci/mirror.py --key-file local/ci/github-mirror
```
The shared check command is:
```sh
python3 tools/ci/check.py
```

29
docs/package-audit.md Normal file
View file

@ -0,0 +1,29 @@
# 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.

View file

@ -8,7 +8,6 @@ The report includes:
- Completed, partial, failed, cancelled and not attempted steps. - Completed, partial, failed, cancelled and not attempted steps.
- Confirmed present Portage packages and skipped or missing packages. - Confirmed present Portage packages and skipped or missing packages.
- NVIDIA module checks/builds for a selected custom kernel and binary fallback.
- Installed, existing, skipped or failed user Flatpaks. - Installed, existing, skipped or failed user Flatpaks.
- Enabled, missing or skipped service units. - Enabled, missing or skipped service units.
- Applied configs, preserved conflicts and pending `.new` proposals. - Applied configs, preserved conflicts and pending `.new` proposals.

View file

@ -1,24 +1,36 @@
# Commands and helpers # Helpers and what they do
| Command/helper | Purpose | | Helper | Purpose | How it is used |
|---|---| |---|---|---|
| `./install` | Opens the TUI; CLI options use the same backend. See [setup](setup.md). | | `install` | Opens the verified prebuilt chooser with a Go source fallback, or forwards CLI options to the Python backend | Main entry point |
| `tools/capture.py` | Refreshes selected config preferences from your home. See [capture](capture.md). | | `lib/launcher.py` | Verifies frontend/binary fingerprints and extracts/caches the bundled TUI | Called by the entry point |
| `tools/shortcuts.py` | Regenerates the readable KDE shortcut reference after recapture. | | `lib/rice.py` | Validates selections, previews plans, restores configs and installs Portage packages | Called by the entry point/TUI |
| `tools/check-private.py` | Checks publishable files for personal paths, identifiers and credentials. | | `lib/device_presets.py` | Saves and lists named local device selections with overwrite prompts | TUI named preset action or backend commands |
| `tools/build-release.py` | Rebuilds the bundled TUI, fingerprints and dependency licences. | | `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend |
| `tools/ci/check.py` | Checks privacy, asset fingerprints, Python syntax and frontend compilation. | | `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 |
| `tools/ci/mirror.py` | Synchronizes branches/tags to GitHub after checks. See [CI](ci.md). | | `lib/backups.py` | Lists and previews user config backups, restores selected files and backs up their current versions | TUI backup action or backend commands |
| `fiw-shot` | Screenshot region/monitor, copy to clipboard, then offer Save/Edit. Installed with KDE shortcuts. | | `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 |
| `optional/fiw-cachy-patch` | Manually generates/installs a matching CachyOS kernel patch and updates the installed guard/name. | | `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 |
| `optional/spotify/spotify-patch` | Runs the optional Spotify customization workflow. | | `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 |
| `optional/spotify/spicetify-snapshotfix` | Supplies missing Marketplace fragments for that customization. | | `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 |
| `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 custom-kernel helper does not update the repo's `snapshot.json`, patch The kernel refresh helper installed on a target runs with `--refresh`. Its
checksum or version mask. Review those together when refreshing the snapshot. kernel hooks are created only when boot deployment is explicitly confirmed;
they are not enabled by the default `keep` choice.
Confirmed boot deployment installs its own refresh helper and kernel hooks. The older Cachy patch helper predates the snapshot guard shipped by this
The default `keep` choice leaves them disabled. See [boot setup](boot.md). 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.
Spotify modifications are optional and at your own risk. This project does For the optional Spotify scripts, see [the setup and risk note](spotify.md).
not endorse bypassing paid features. See [Spotify setup](spotify.md). Unofficial Spotify modifications are optional and used at your own risk.
This project does not endorse bypassing paid features.

View file

@ -65,21 +65,6 @@ recommended. Skipping a dependency also skips requested packages needing it.
A package report records requested, skipped and missing packages. There is a A package report records requested, skipped and missing packages. There is a
final package confirmation before live Portage configuration changes. final package confirmation before live Portage configuration changes.
Binary packages retain the binhost's USE settings; required dependency flags
are still checked. The preview includes dependency updates and replacements,
such as adding Steam's 32-bit libraries to a fresh multilib installation.
Custom kernel installs with NVIDIA also check its modules against the selected
custom kernel and binary fallback. Missing or outdated modules are compiled
for each, and failures appear in the package report.
The custom kernel uses the CachyOS 7.2.10-1 patch and is named `7.2.10-fiw-nyx-v1.2`. Newer
7.2.x point releases reuse that patch and config; the next series stays masked
until the snapshot is updated. Fiw's Ryzen preset also installs
`/etc/sysctl.d/70-fiw-dots-performance.conf` (CachyOS-style memory/network
values, `vm.max_map_count` for Proton games, TCP BBR with fq) and loads
`tcp_bbr`. No extra packages are needed for either.
Read-only resolution and an export are available separately: Read-only resolution and an export are available separately:
```sh ```sh
@ -155,19 +140,11 @@ or Btrfs roots, and unsigned boot. See [boot setup](boot.md) for previews,
firmware choices and kernel update integration. Other layouts can keep their firmware choices and kernel update integration. Other layouts can keep their
existing loader. existing loader.
Gentoo QEMU/KVM checks covered a fresh Stock installation, Fiw's Ryzen custom The optional [TideWM recipe](tidewm.md) follows the audited upstream master
kernel and binary fallback, all package sections, the complete TUI restore, branch and uses the normal compile-or-skip prompt. End-to-end package and
all 16 config presets, capture filtering, preserved edits, proposals and backups. boot execution on a fresh Gentoo
Plasma Login Manager, KDE/GTK styling, shortcuts, optional autostart, service installation remains unverified; development checks used temporary homes,
enabling and both user Flatpaks were checked. GRUB and Limine booted ext4 and staged Portage configuration and mocked system commands.
a Btrfs `@` root; kernel addition/removal hooks and menu conflicts were checked.
The Btrfs guest used a converted copy of the installed ext4 guest.
App checks cover launches and local configuration, including MangoHud,
GameMode, FiwNode audio and Apdatifier. [TideWM](tidewm.md) compiled and ran
with virtual graphics. NVIDIA modules were built and checked for both selected
kernels. Physical NVIDIA rendering, Wi-Fi/Bluetooth devices, real audio hardware,
account sign-ins and gameplay still need checks on the target device.
Do not publish the private original fiw-gentoo archive. It has different Do not publish the private original fiw-gentoo archive. It has different
history and host-specific files. history and host-specific files.

View file

@ -19,11 +19,8 @@ is enabled by default, including the session-specific portal files.
This is a source build. The main installer shows it as an additional This is a source build. The main installer shows it as an additional
compile-or-skip choice; it is not part of the deliberately small source list. compile-or-skip choice; it is not part of the deliberately small source list.
A live recipe follows future upstream changes, while the current audit SHA A live recipe follows future upstream changes, while the current audit SHA
documents the version reviewed for this snapshot. That commit compiled and documents the version reviewed for this snapshot. Compilation of the
ran in the Gentoo QEMU/KVM guest, both nested inside Plasma and directly from refreshed tree on a fresh device remains unverified.
a TTY with virtual graphics. Kitty started Fish with the restored terminal
preferences in both sessions. Physical GPU and screencasting behaviour remain
device-specific checks.
After installation, choose **TideWM** at login. It creates its own default After installation, choose **TideWM** at login. It creates its own default
configuration on first launch. No existing TideWM settings are captured or configuration on first launch. No existing TideWM settings are captured or

View file

@ -10,7 +10,6 @@ import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -28,31 +27,13 @@ def mount_info(path, exact=False):
text=True, capture_output=True) text=True, capture_output=True)
if result.returncode: if result.returncode:
raise RuntimeError('Not a mounted filesystem: ' + str(path)) raise RuntimeError('Not a mounted filesystem: ' + str(path))
# An x-systemd.automount point lists its autofs placeholder first. data = json.loads(result.stdout)['filesystems'][0]
mounted = [fs for fs in json.loads(result.stdout)['filesystems'] if fs['fstype'] != 'autofs'] return data
if not mounted:
raise RuntimeError('Not a mounted filesystem: ' + str(path))
return mounted[-1]
def fstab_vfat():
result = subprocess.run(['findmnt', '--fstab', '--json', '--types', 'vfat', '--output', 'TARGET'],
text=True, capture_output=True)
if result.returncode or not result.stdout.strip():
return []
return [Path(fs['target']) for fs in json.loads(result.stdout)['filesystems']]
def discover_esp(path=None): def discover_esp(path=None):
candidates = [Path(path)] if path else [Path('/efi'), Path('/boot/efi'), Path('/boot')] candidates = [Path(path)] if path else [Path('/efi'), Path('/boot/efi'), Path('/boot')]
if not path:
candidates += [target for target in fstab_vfat() if target not in candidates]
for candidate in candidates: for candidate in candidates:
# Accessing an automount point mounts it; findmnt alone does not.
try:
os.listdir(candidate)
except OSError:
pass
try: try:
info = mount_info(candidate, exact=True) info = mount_info(candidate, exact=True)
except RuntimeError: except RuntimeError:
@ -248,35 +229,7 @@ def write(path, content, backups, mode=0o644):
tmp.replace(path) tmp.replace(path)
def grub_config(output, images, exclude=None): def refresh(saved, backups, exclude=None):
env = dict(os.environ, GRUB_TOP_LEVEL=images[0]['kernel'])
command = ['grub-mkconfig', '-o', str(output)]
if not exclude:
subprocess.run(command, env=env, check=True)
return
# Removal hooks can run before the kernel file disappears. Filter discovery
# through GRUB's library override, without moving installed boot artifacts.
library = Path('/usr/share/grub')
source = (library / 'grub-mkconfig_lib').read_text()
source, count = re.subn(r'\bgrub_file_is_not_garbage\s*\(\s*\)',
'fiw_dots_file_is_not_garbage ()', source, count=1)
if count != 1:
raise RuntimeError('Cannot filter this GRUB library; preserving the boot menu.')
paths = ['/boot/kernel-' + exclude] + [prefix + exclude for prefix in
('/boot/vmlinuz-', '/vmlinuz-', '/boot/vmlinux-', '/vmlinux-')]
source += '\ngrub_file_is_not_garbage () {\n case "$1" in\n ' + '|'.join(
shlex.quote(path) for path in paths) + ') return 1 ;;\n esac\n fiw_dots_file_is_not_garbage "$@"\n}\n'
with tempfile.TemporaryDirectory(prefix='fiw-dots-grub-') as directory:
root = Path(directory)
for path in library.iterdir():
if path.name != 'grub-mkconfig_lib':
(root / path.name).symlink_to(path)
(root / 'grub-mkconfig_lib').write_text(source)
subprocess.run(command, env=dict(env, pkgdatadir=directory), check=True)
def refresh(saved, backups, exclude=None, ask=None):
saved.pop('pending_config', None)
# Recheck the mount every time; never write into an unmounted ESP directory. # Recheck the mount every time; never write into an unmounted ESP directory.
target = discover_esp(Path(saved['esp'])) target = discover_esp(Path(saved['esp']))
images = kernel_images(exclude=exclude) images = kernel_images(exclude=exclude)
@ -293,10 +246,7 @@ def refresh(saved, backups, exclude=None, ask=None):
config = GRUB_DIRECTORY / 'grub/grub.cfg' config = GRUB_DIRECTORY / 'grub/grub.cfg'
old_hash = file_hash(config) old_hash = file_hash(config)
output = config output = config
edited = old_hash and old_hash != saved.get('config_hash') if old_hash and old_hash != saved.get('config_hash'):
replace = edited and ask and ask('Existing GRUB menu differs from the saved version. Replace it with the generated menu?',
['replace', 'keep'], 'keep') == 'replace'
if edited and not replace:
output = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')) output = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
else: else:
backup(config, backups) backup(config, backups)
@ -314,13 +264,12 @@ def refresh(saved, backups, exclude=None, ask=None):
shutil.copy2(source, dest) shutil.copy2(source, dest)
scratch = output.with_name('.' + output.name + '.tmp') scratch = output.with_name('.' + output.name + '.tmp')
try: try:
grub_config(scratch, images, exclude) subprocess.run(['grub-mkconfig', '-o', str(scratch)], check=True)
scratch.replace(output) scratch.replace(output)
finally: finally:
if scratch.exists(): if scratch.exists():
scratch.unlink() scratch.unlink()
if output != config: if output != config:
saved['pending_config'] = str(output)
print('Preserved edited GRUB config; review ' + str(output)) print('Preserved edited GRUB config; review ' + str(output))
else: else:
saved['config_hash'] = file_hash(config) saved['config_hash'] = file_hash(config)
@ -346,37 +295,16 @@ def refresh(saved, backups, exclude=None, ask=None):
prior = managed_block(old) prior = managed_block(old)
block = render_limine(images, saved['cmdline']) block = render_limine(images, saved['cmdline'])
output = merge_limine(old, block) output = merge_limine(old, block)
edited = prior and block_hash(prior) != saved.get('block_hash') if prior and block_hash(prior) != saved.get('block_hash'):
replace = edited and ask and ask('Existing Limine entries differ from the saved version. Replace the managed entries?',
['replace', 'keep'], 'keep') == 'replace'
if edited and not replace:
proposal = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')) proposal = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
write(proposal, output, backups) write(proposal, output, backups)
saved['pending_config'] = str(proposal)
print('Preserved edited Limine entries; review ' + str(proposal)) print('Preserved edited Limine entries; review ' + str(proposal))
else: elif output != old:
if output != old: write(config, output, backups)
write(config, output, backups) saved['block_hash'] = block_hash(block)
saved['block_hash'] = block_hash(block)
# Only once the menu no longer lists them: drop removed kernels' copies.
prune_kernels(folder / 'kernels', {image['version'] for image in images})
return saved return saved
def prune_kernels(directory, keep):
"""Remove ESP kernel folders that refresh created and the menu no longer uses."""
if not directory.is_dir():
return
for path in directory.iterdir():
if path.name in keep or path.is_symlink() or not path.is_dir():
continue
names = {child.name for child in path.iterdir()}
# Leave anything that is not purely this tool's vmlinuz/initramfs copy.
if names <= {'vmlinuz', 'initramfs.img', '.vmlinuz.tmp', '.initramfs.img.tmp'}:
shutil.rmtree(path)
print('Removed unused ESP kernel copy: ' + path.name)
def confirm(prompt, choices, default): def confirm(prompt, choices, default):
if not sys.stdin.isatty(): if not sys.stdin.isatty():
raise RuntimeError('Interactive confirmation required: ' + prompt) raise RuntimeError('Interactive confirmation required: ' + prompt)
@ -453,12 +381,7 @@ def deploy(mode, esp=None, ask=confirm):
else: else:
subprocess.run(proposed['commands'][0], check=True) subprocess.run(proposed['commands'][0], check=True)
loader_path = '/EFI/Fiw-Gentoo/grubx64.efi' loader_path = '/EFI/Fiw-Gentoo/grubx64.efi'
saved = refresh(saved, backups, ask=ask) saved = refresh(saved, backups)
pending = saved.pop('pending_config', None)
if pending and firmware != 'keep':
commands = []
firmware = 'keep'
print('Kept the existing boot menu; firmware entries and order remain unchanged. Review ' + pending)
# Native installkernel provides versioned /boot kernel/initrd files; our # Native installkernel provides versioned /boot kernel/initrd files; our
# final hook refreshes only the chosen loader's own configuration. # final hook refreshes only the chosen loader's own configuration.
write(Path('/etc/kernel/install.conf'), proposed['installkernel_config'], backups) write(Path('/etc/kernel/install.conf'), proposed['installkernel_config'], backups)
@ -480,8 +403,7 @@ def deploy(mode, esp=None, ask=confirm):
report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware, report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware,
'backups': str(backups)}, indent=2) + '\n') 'backups': str(backups)}, indent=2) + '\n')
print('Boot deployment finished. Report: ' + str(report)) print('Boot deployment finished. Report: ' + str(report))
return {'status': 'partial' if pending else 'completed', 'mode': mode, 'firmware_choice': firmware, return {'status': 'completed', 'mode': mode, 'firmware_choice': firmware,
'review': [pending] if pending else [],
'report': str(report), 'backups': str(backups)} 'report': str(report), 'backups': str(backups)}
@ -500,7 +422,6 @@ def main():
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
backups = MANAGED / 'backups' / ('boot-refresh-' + stamp) backups = MANAGED / 'backups' / ('boot-refresh-' + stamp)
updated = refresh(saved, backups, args.exclude_version) updated = refresh(saved, backups, args.exclude_version)
updated.pop('pending_config', None)
write(STATE, json.dumps(updated, indent=2) + '\n', backups, 0o600) write(STATE, json.dumps(updated, indent=2) + '\n', backups, 0o600)

View file

@ -341,7 +341,7 @@ def root_files(selection, repo_location=None):
result['etc/portage/sets/fiw-dots-' + group] = '\n'.join(packages) + '\n' result['etc/portage/sets/fiw-dots-' + group] = '\n'.join(packages) + '\n'
result['etc/portage/sets/fiw-dots'] = '\n'.join('@fiw-dots-' + g for g in package_map(selection)) + '\n' result['etc/portage/sets/fiw-dots'] = '\n'.join('@fiw-dots-' + g for g in package_map(selection)) + '\n'
result['etc/portage/fiw-dots.conf'] += '\n# Persist the selected binary/source policy for later emerge operations.\n' result['etc/portage/fiw-dots.conf'] += '\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 --usepkg --binpkg-respect-use=n --with-bdeps=n ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n' result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n'
result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n' result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n'
for name in required_repositories(selection): for name in required_repositories(selection):
if not existing_repository(name): if not existing_repository(name):
@ -353,12 +353,7 @@ def root_files(selection, repo_location=None):
result['etc/kernel/config.d/' + src.name] = src.read_text() result['etc/kernel/config.d/' + src.name] = src.read_text()
result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n' result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n'
result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text() result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text()
# Point releases of the tested series reuse the patch (see guard); a new series needs a new snapshot. 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'
major, minor = json.loads((REPO / 'optional/kernel/snapshot.json').read_text())['patch_kernel'].split('.')[:2]
result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel series; update snapshot and patch for the next one.\n>=sys-kernel/gentoo-kernel-' + major + '.' + str(int(minor) + 1) + '\n'
if selection['profile'] == 'fiw-ryzen':
result['etc/sysctl.d/70-fiw-dots-performance.conf'] = (REPO / 'optional/tuning/70-fiw-performance.conf').read_text()
result['etc/modules-load.d/fiw-dots-bbr.conf'] = (REPO / 'optional/tuning/bbr.conf').read_text()
if selection['bootloader'] != 'keep': 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') 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': if selection['bootloader'] == 'grub':
@ -430,20 +425,16 @@ def source_builds(output):
values = set(re.findall(r'^\[ebuild[^\]]*\]\s+(?:\([^)]*\)\s+)?([^\s:]+)', output, re.M)) values = set(re.findall(r'^\[ebuild[^\]]*\]\s+(?:\([^)]*\)\s+)?([^\s:]+)', output, re.M))
# Dedicated -bin ebuilds install upstream binaries; these do not require # Dedicated -bin ebuilds install upstream binaries; these do not require
# compiling the application just because Portage labels them "ebuild". # compiling the application just because Portage labels them "ebuild".
# These install upstream binaries or QML rather than compile applications. # This released Plasma widget installs QML/scripts without a compile phase.
no_compile = {'app-editors/vscode', 'media-sound/spotify', no_compile = {'kde-misc/apdatifier-gentoo'}
'games-util/steam-launcher', 'kde-misc/apdatifier-gentoo'}
return {value for value in values if not cp_from_cpv(value).endswith('-bin') return {value for value in values if not cp_from_cpv(value).endswith('-bin')
and not value.startswith(('acct-user/', 'acct-group/', 'virtual/'))
and cp_from_cpv(value) not in no_compile} and cp_from_cpv(value) not in no_compile}
def resolve(stage, selection, packages, execute=False): def resolve(stage, selection, packages, execute=False):
exclusions = ' '.join(source_policy(selection)) exclusions = ' '.join(source_policy(selection))
command = ['emerge', '--config-root=' + str(stage), '--color=n', '--getbinpkg', '--usepkg', command = ['emerge', '--config-root=' + str(stage), '--color=n', '--getbinpkg', '--usepkg',
'--binpkg-respect-use=n', '--with-bdeps=n', '--usepkg-exclude=' + exclusions, '--autounmask=n', '--noreplace']
'--usepkg-exclude=' + exclusions, '--autounmask=n',
'--update', '--deep', '--newuse', '--backtrack=100']
if not execute: if not execute:
command += ['--pretend'] command += ['--pretend']
env = dict(os.environ, EMERGE_DEFAULT_OPTS='') env = dict(os.environ, EMERGE_DEFAULT_OPTS='')
@ -452,61 +443,6 @@ def resolve(stage, selection, packages, execute=False):
stderr=None if execute else subprocess.STDOUT) stderr=None if execute else subprocess.STDOUT)
def nvidia_modules():
"""Check both selected distribution kernels, not just /usr/src/linux."""
snapshot = json.loads((REPO / 'optional/kernel/snapshot.json').read_text())
driver = subprocess.check_output(['portageq', 'best_version', '/', 'x11-drivers/nvidia-drivers'], text=True).strip()
expected = re.sub(r'-r\d+$', '', driver.split('nvidia-drivers-', 1)[1])
result = {'checked': [], 'rebuilt': [], 'missing': []}
for atom in [snapshot['atom'], 'sys-kernel/gentoo-kernel-bin']:
cpv = subprocess.check_output(['portageq', 'best_version', '/', atom], text=True).strip()
contents = (Path('/var/db/pkg') / cpv / 'CONTENTS').read_text() if cpv else ''
versions = sorted(set(re.findall(r'/(?:usr/)?lib/modules/([^/ ]+)/', contents)))
if not versions:
result['missing'].append('NVIDIA kernel target: ' + atom)
for version in versions:
result['checked'].append(version)
def ready():
return all(subprocess.run(['modinfo', '-k', version, '-F', 'version', module],
text=True, capture_output=True).stdout.strip() == expected
for module in ['nvidia', 'nvidia_drm', 'nvidia_modeset', 'nvidia_uvm'])
if ready():
continue
build = Path('/lib/modules') / version / 'build'
print('Building NVIDIA modules for ' + version, flush=True)
command = ['emerge', '--oneshot', '--nodeps', '--usepkg=n', '--getbinpkg=n',
'--autounmask=n', '=' + driver]
if build.is_dir() and subprocess.run(command, env=dict(os.environ, KERNEL_DIR=str(build))).returncode == 0 and ready():
result['rebuilt'].append(version)
else:
result['missing'].append('NVIDIA modules for ' + version)
return result
def replace_tree(source, target):
"""Replace target with a copy of source; merging would keep removed ebuilds
next to a Manifest that no longer lists their distfiles."""
target.parent.mkdir(parents=True, exist_ok=True)
scratch = target.with_name('.' + target.name + '-new-' + str(os.getpid()))
retired = target.with_name('.' + target.name + '-old-' + str(os.getpid()))
try:
shutil.copytree(source, scratch, symlinks=True)
if target.exists() or target.is_symlink():
target.rename(retired)
try:
scratch.rename(target)
except OSError:
if retired.exists() and not target.exists():
retired.rename(target)
raise
finally:
for leftover in (scratch, retired):
if leftover.is_symlink():
leftover.unlink()
elif leftover.exists():
shutil.rmtree(leftover)
def install_packages(selection): def install_packages(selection):
if os.geteuid() != 0: if os.geteuid() != 0:
raise RuntimeError('Package installation requires root. Run sudo ./install --install-packages --selection <file>.') raise RuntimeError('Package installation requires root. Run sudo ./install --install-packages --selection <file>.')
@ -519,8 +455,6 @@ def install_packages(selection):
raise RuntimeError('No packages selected') raise RuntimeError('No packages selected')
with tempfile.TemporaryDirectory(prefix='fiw-dots-portage-') as temp: with tempfile.TemporaryDirectory(prefix='fiw-dots-portage-') as temp:
stage = Path(temp) stage = Path(temp)
# Portage evaluates ebuild metadata as its unprivileged user.
stage.chmod(0o755)
shutil.copytree('/etc/portage', stage / 'etc/portage', symlinks=True, shutil.copytree('/etc/portage', stage / 'etc/portage', symlinks=True,
ignore=shutil.ignore_patterns('gnupg')) ignore=shutil.ignore_patterns('gnupg'))
profile = stage / 'etc/portage/make.profile' profile = stage / 'etc/portage/make.profile'
@ -564,11 +498,6 @@ def install_packages(selection):
if not accepted: if not accepted:
print('All requested packages were skipped; no live configuration applied.') print('All requested packages were skipped; no live configuration applied.')
return {'status': 'skipped', 'requested': requested, 'skipped': skipped, 'missing': requested} return {'status': 'skipped', 'requested': requested, 'skipped': skipped, 'missing': requested}
build_modules = (selection['kernel'] == 'custom' and 'system' in selection['groups']
and 'x11-drivers/nvidia-drivers' in accepted
and any(atom.startswith('=sys-kernel/gentoo-kernel-') for atom in accepted))
if build_modules:
print('NVIDIA modules will be checked and compiled when needed for the selected custom kernel and binary fallback.')
if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply': if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply':
return {'status': 'cancelled', 'requested': requested, 'skipped': skipped} return {'status': 'cancelled', 'requested': requested, 'skipped': skipped}
publish_repositories(fetched) publish_repositories(fetched)
@ -602,7 +531,7 @@ def install_packages(selection):
overlay = Path('/var/db/repos/fiw-dots') overlay = Path('/var/db/repos/fiw-dots')
if overlay.exists(): if overlay.exists():
shutil.copytree(overlay, backup_root / 'overlay', symlinks=True) shutil.copytree(overlay, backup_root / 'overlay', symlinks=True)
replace_tree(REPO / 'overlay', overlay) shutil.copytree(REPO / 'overlay', overlay, dirs_exist_ok=True, symlinks=True)
make_conf = Path('/etc/portage/make.conf') make_conf = Path('/etc/portage/make.conf')
include = 'source /etc/portage/fiw-dots.conf' include = 'source /etc/portage/fiw-dots.conf'
if include not in make_conf.read_text(): if include not in make_conf.read_text():
@ -621,24 +550,20 @@ def install_packages(selection):
if final.returncode or ({cp_from_cpv(x) for x in source_builds(final.stdout)} - approved): if final.returncode or ({cp_from_cpv(x) for x in source_builds(final.stdout)} - approved):
raise RuntimeError('The final package plan changed. Review it before retrying; backups are at ' + str(backup_root)) raise RuntimeError('The final package plan changed. Review it before retrying; backups are at ' + str(backup_root))
result = resolve(Path('/'), selection, ['@fiw-dots-selected'], execute=True) result = resolve(Path('/'), selection, ['@fiw-dots-selected'], execute=True)
modules = nvidia_modules() if result.returncode == 0 and build_modules else {}
missing = [atom for atom in requested if subprocess.run( missing = [atom for atom in requested if subprocess.run(
['portageq', 'has_version', '/', atom], stdout=subprocess.DEVNULL, ['portageq', 'has_version', '/', atom], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode != 0] stderr=subprocess.DEVNULL).returncode != 0]
report = {'requested': requested, 'present': [atom for atom in accepted if atom not in missing], report = {'requested': requested, 'present': [atom for atom in accepted if atom not in missing],
'skipped': skipped, 'emerge_exit_code': result.returncode, 'skipped': skipped, 'emerge_exit_code': result.returncode,
'missing': missing, 'missing': missing,
'modules': modules, 'failed': modules.get('missing', []),
'pending': []} 'pending': []}
state = Path('/var/lib/Fiw-Gentoo-Dots') state = Path('/var/lib/Fiw-Gentoo-Dots')
state.mkdir(parents=True, exist_ok=True) state.mkdir(parents=True, exist_ok=True)
(state / ('packages-' + stamp + '.json')).write_text(json.dumps(report, indent=2) + '\n') (state / ('packages-' + stamp + '.json')).write_text(json.dumps(report, indent=2) + '\n')
print('Package report: ' + str(state / ('packages-' + stamp + '.json'))) print('Package report: ' + str(state / ('packages-' + stamp + '.json')))
print('Missing packages: ' + (', '.join(missing) or 'none')) print('Missing packages: ' + (', '.join(missing) or 'none'))
if build_modules and result.returncode == 0: if result.returncode:
print('Missing NVIDIA modules: ' + (', '.join(modules.get('missing', [])) or 'none')) error = RuntimeError('Some packages did not install; consult the package report and emerge output.')
if result.returncode or modules.get('missing'):
error = RuntimeError('Some packages or kernel modules did not install; consult the package report and emerge output.')
error.report = report error.report = report
raise error raise error
if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing: if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing:
@ -652,7 +577,6 @@ def check_packages(selection):
requested = list(dict.fromkeys(p for values in package_map(selection).values() for p in values)) requested = list(dict.fromkeys(p for values in package_map(selection).values() for p in values))
with tempfile.TemporaryDirectory(prefix='fiw-dots-check-') as temp: with tempfile.TemporaryDirectory(prefix='fiw-dots-check-') as temp:
stage = Path(temp) stage = Path(temp)
stage.chmod(0o755)
shutil.copytree('/etc/portage', stage / 'etc/portage', symlinks=True, shutil.copytree('/etc/portage', stage / 'etc/portage', symlinks=True,
ignore=shutil.ignore_patterns('gnupg')) ignore=shutil.ignore_patterns('gnupg'))
profile = stage / 'etc/portage/make.profile' profile = stage / 'etc/portage/make.profile'

View file

@ -15,10 +15,9 @@ trap 'rm -rf "$work"' EXIT
cd "$work" cd "$work"
# Newest CachyOS release for this kernel version (cachyos-7.2.8-1, -2, ...) # Newest CachyOS release for this kernel version (cachyos-7.2.8-1, -2, ...)
releases=$(curl -fsSL "https://api.github.com/repos/CachyOS/linux/releases?per_page=50") tag=$(curl -fsSL "https://api.github.com/repos/CachyOS/linux/releases?per_page=50" |
tag=$(printf '%s\n' "$releases" |
grep -oE "\"tag_name\": *\"cachyos-${ver//./\\.}-[0-9]+\"" | grep -oE "\"tag_name\": *\"cachyos-${ver//./\\.}-[0-9]+\"" |
grep -oE "cachyos-[0-9.]+-[0-9]+" | sort -V | tail -1 || true) grep -oE "cachyos-[0-9.]+-[0-9]+" | sort -V | tail -1)
[[ -n $tag ]] || { echo "CachyOS has no release for $ver yet" >&2; exit 1; } [[ -n $tag ]] || { echo "CachyOS has no release for $ver yet" >&2; exit 1; }
echo "Using $tag" echo "Using $tag"
@ -40,6 +39,8 @@ mkdir -p "$patchdir"
rm -f "$patchdir"/0100-cachyos-*.patch rm -f "$patchdir"/0100-cachyos-*.patch
install -m644 "${tag}.patch" "$patchdir/0100-${tag}.patch" install -m644 "${tag}.patch" "$patchdir/0100-${tag}.patch"
sed -i "s/^FIW_CACHY_PV=.*/FIW_CACHY_PV=${ver}/" "$guard" sed -i "s/^FIW_CACHY_PV=.*/FIW_CACHY_PV=${ver}/" "$guard"
# kernel name: <ver>-<cachyos release>-<rest>, only the release number changes
sed -i -E "s/^CONFIG_LOCALVERSION=\"-[0-9]+-/CONFIG_LOCALVERSION=\"-${tag##*-}-/" "$name_cfg"
grep ^CONFIG_LOCALVERSION "$name_cfg" grep ^CONFIG_LOCALVERSION "$name_cfg"
echo "Installed $patchdir/0100-${tag}.patch; guard now allows ${ver}." echo "Installed $patchdir/0100-${tag}.patch; guard now allows ${ver}."
echo "Next: emerge -1 =sys-kernel/gentoo-kernel-${ver}" echo "Next: emerge -1 =sys-kernel/gentoo-kernel-${ver}"

54
optional/fiw-limine-splice Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# fiw-limine-splice <esp-mountpoint>
# Replaces the fiw-gentoo block in <esp>/limine.conf with the snippet Gentoo left
# at <esp>/<gentoo-machine-id>/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 <esp>}
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"

View file

@ -1,7 +1,5 @@
# uname -r → <linux version>-fiw-nyx-v<own version>, e.g. 7.2.10-fiw-nyx-v1.2. # uname -r → <linux version>-<cachyos release>-fiw-nyx-v<own version>
# Only the kernel version plus Fiw's name: no CachyOS release number or other extras. # e.g. 7.2.7-1-fiw-nyx-v1.1. fiw-cachy-patch updates the cachyos release number;
# Bump v<N> by hand when you change your own tweaks. # bump v<N> by hand when you change your own tweaks.
# History: v1 = "-1-fiwzen-v1" (first build), v1.1 = renamed to fiw-nyx, # History: v1 = "-1-fiwzen-v1" (first build), v1.1 = renamed to fiw-nyx.
# v1.2 = 2026-10-08 rebuild (7.2.9, CachyOS 7.2.9-2 patch), "-<cachyos release>" dropped CONFIG_LOCALVERSION="-1-fiw-nyx-v1.1"
# from the name from the next kernel build on (first: 7.2.10, CachyOS 7.2.10-1 patch).
CONFIG_LOCALVERSION="-fiw-nyx-v1.2"

View file

@ -3,17 +3,14 @@
export KBUILD_BUILD_USER=fiw export KBUILD_BUILD_USER=fiw
export KBUILD_BUILD_HOST=gentoo export KBUILD_BUILD_HOST=gentoo
# Version the CachyOS patch in /etc/portage/patches was generated for. # The CachyOS patch is a diff against exactly one kernel version. Refuse to build
# fiw-cachy-patch rewrites this line (the postsync hook runs it when CachyOS # any other version until scripts/fiw-cachy-patch has regenerated it.
# has a release for a new gentoo-kernel). A newer point release in the same # Versions the current patch is known to apply to (space separated).
# series (7.2.x) reuses the existing patch and config.d unchanged; if the patch # 2026-09-26: CachyOS has no 7.2.8 yet; the 7.2.7-1 patch applies to 7.2.8 (3 hunks
# no longer applies, the build stops and the installed kernels stay as they are. # with fuzz, checked by hand: only context shifted by 7.2.8's APX lines).
FIW_CACHY_PV=7.2.10 FIW_CACHY_PV="7.2.7 7.2.8"
pre_src_prepare() { pre_src_prepare() {
[[ ${PV} == "${FIW_CACHY_PV}" ]] && return if [[ " ${FIW_CACHY_PV} " != *" ${PV} "* ]]; then
if [[ ${PV%.*} != "${FIW_CACHY_PV%.*}" ]]; then die "CachyOS patch is for ${FIW_CACHY_PV}, not ${PV}. Run: fiw-cachy-patch ${PV}"
die "CachyOS patch is for ${FIW_CACHY_PV}; ${PV} is a new series. Run: fiw-cachy-patch ${PV}"
fi fi
ewarn "No CachyOS patch for ${PV} yet: reusing the ${FIW_CACHY_PV} patch and config."
ewarn "Fallbacks if it misbehaves: gentoo-kernel-bin entry, or CachyOS."
} }

View file

@ -1,9 +1,10 @@
{ {
"atom": "=sys-kernel/gentoo-kernel-7.2.10", "atom": "=sys-kernel/gentoo-kernel-7.2.8",
"patch_kernel": "7.2.10", "patch_kernel": "7.2.7",
"cachyos_tag": "cachyos-7.2.10-1", "cachyos_tag": "cachyos-7.2.7-1",
"tested_kernel_versions": [ "tested_kernel_versions": [
"7.2.10" "7.2.7",
"7.2.8"
], ],
"patch_sha256": "ed5f06622f9045ac3938e7c89270622f647252dd6e9e6c699b7ff8368a829b1a" "patch_sha256": "c1aca3cbd113866e5660cb827403ee73a8ca6f0eb5dcb497fad90561e059ef75"
} }

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# SpotX-Bash + spicetify (Marketplace) on Gentoo's media-sound/spotify. # SpotX-Bash + spicetify (Marketplace) on Gentoo's media-sound/spotify.
# Run as your regular user after the first setup and after every Spotify update. # Run as your regular user after the first setup and after every Spotify update.
# Write access must be configured explicitly before opting in. # Write access comes from an ACL (/etc/portage/env/media-sound/spotify).
set -euo pipefail set -euo pipefail
client=/opt/spotify/spotify-client client=/opt/spotify/spotify-client
@ -9,7 +9,7 @@ spotx_url=https://raw.githubusercontent.com/SpotX-Official/SpotX-Bash/main/spotx
spicetify=$HOME/.spicetify/spicetify spicetify=$HOME/.spicetify/spicetify
[[ -w $client/Apps/xpui.spa && -w $client/spotify ]] || { [[ -w $client/Apps/xpui.spa && -w $client/spotify ]] || {
echo "No write access to $client. Review docs/spotify.md and configure client write access explicitly before opting in." >&2 echo "No write access to $client. Run once as root: the optional Spotify ACL setup described in docs/spotify.md" >&2
exit 1 exit 1
} }

View file

@ -1,22 +0,0 @@
# Installed as /etc/sysctl.d/70-fiw-dots-performance.conf (Fiw's Ryzen preset)
# Desktop/gaming tuning, mostly from CachyOS-Settings (70-cachyos-settings.conf).
# swappiness 100 / page-cluster 0 (zram) and nmi_watchdog 0 already come from the CachyOS patch.
# Games under Proton (CS2, Star Citizen, Hogwarts Legacy...) need many memory maps.
# Gentoo default 65530; Arch/CachyOS/Fedora use 1048576.
vm.max_map_count = 1048576
# Keep directory/inode cache longer (default 100).
vm.vfs_cache_pressure = 50
# Flush dirty pages in small chunks instead of % of 32 GB RAM: fewer stalls on big writes.
vm.dirty_bytes = 268435456
vm.dirty_background_bytes = 67108864
vm.dirty_writeback_centisecs = 1500
# Bigger network receive queue.
net.core.netdev_max_backlog = 4096
# TCP BBR congestion control with fq pacing (module loaded by modules-load.d/bbr.conf).
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

View file

@ -1,2 +0,0 @@
# Installed as /etc/modules-load.d/fiw-dots-bbr.conf (Fiw's Ryzen preset)
tcp_bbr

View file

@ -0,0 +1,3 @@
{
"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."
}

View file

@ -14,4 +14,3 @@ dev-qt/qtdeclarative
media-video/pipewire media-video/pipewire
dev-db/sqlite dev-db/sqlite
llvm-core/clang llvm-core/clang
gui-apps/grim

View file

@ -2,4 +2,4 @@
gui-wm/tidewm::fiw-dots gui-wm/tidewm::fiw-dots
gui-apps/xwayland-satellite::guru gui-apps/xwayland-satellite::guru
gui-apps/wlr-randr::guru gui-apps/wlr-randr::guru
gui-apps/mpvpaper gui-apps/grim

View file

@ -1,8 +1,8 @@
llvm-core/llvm fiw-dots-heavy.conf fiw-dots-notmpfs.conf llvm-core/llvm heavy.conf notmpfs.conf
llvm-core/clang fiw-dots-heavy.conf fiw-dots-notmpfs.conf llvm-core/clang heavy.conf notmpfs.conf
dev-qt/qtwebengine fiw-dots-heavy.conf fiw-dots-notmpfs.conf dev-qt/qtwebengine heavy.conf notmpfs.conf
net-libs/nodejs fiw-dots-heavy.conf net-libs/nodejs heavy.conf
dev-lang/spidermonkey fiw-dots-heavy.conf dev-lang/spidermonkey heavy.conf
media-libs/embree fiw-dots-heavy.conf fiw-dots-notmpfs.conf media-libs/embree heavy.conf notmpfs.conf
media-gfx/blender fiw-dots-heavy.conf fiw-dots-notmpfs.conf media-gfx/blender heavy.conf notmpfs.conf
media-gfx/openvdb fiw-dots-heavy.conf fiw-dots-notmpfs.conf media-gfx/openvdb heavy.conf notmpfs.conf

View file

@ -1,2 +1,2 @@
sys-kernel/gentoo-kernel fiw-dots-notmpfs.conf sys-kernel/gentoo-kernel notmpfs.conf
sys-devel/gcc fiw-dots-notmpfs.conf sys-devel/gcc notmpfs.conf

View file

@ -1 +1 @@
media-libs/opencv -vaapi VIDEO_CARDS: intel CPU_FLAGS_X86: -* sse sse2 media-libs/opencv -vaapi VIDEO_CARDS: intel CPU_FLAGS_X86: -* avx avx2 f16c fma3 popcnt sse sse2 sse3 sse4_1 sse4_2 ssse3

View file

@ -1,4 +1,3 @@
media-video/obs-studio browser websocket nvenc v4l media-video/obs-studio browser websocket nvenc v4l
media-video/ffmpeg opus nvenc lame theora xvid vpx media-video/ffmpeg opus nvenc lame theora xvid vpx
app-arch/7zip rar app-arch/7zip rar
dev-libs/libdbusmenu gtk3

View file

@ -1,2 +1 @@
*/* CPU_FLAGS_X86: aes avx avx2 avx512_bf16 avx512_bitalg avx512_vbmi2 avx512_vnni avx512_vp2intersect avx512_vpopcntdq avx512bw avx512cd avx512dq avx512f avx512ifma avx512vbmi avx512vl avx_vnni bmi1 bmi2 f16c fma3 mmx mmxext pclmul popcnt rdrand sha sse sse2 sse3 sse4_1 sse4_2 sse4a ssse3 vpclmulqdq */* CPU_FLAGS_X86: aes avx avx2 avx512_bf16 avx512_bitalg avx512_vbmi2 avx512_vnni avx512_vp2intersect avx512_vpopcntdq avx512bw avx512cd avx512dq avx512f avx512ifma avx512vbmi avx512vl avx_vnni bmi1 bmi2 f16c fma3 mmx mmxext pclmul popcnt rdrand sha sse sse2 sse3 sse4_1 sse4_2 sse4a ssse3 vpclmulqdq
media-libs/opencv CPU_FLAGS_X86: -* avx avx2 f16c fma3 popcnt sse sse2 sse3 sse4_1 sse4_2 ssse3

View file

@ -65,7 +65,6 @@ virtual/zlib abi_x86_32
x11-drivers/nvidia-drivers abi_x86_32 x11-drivers/nvidia-drivers abi_x86_32
x11-libs/cairo abi_x86_32 x11-libs/cairo abi_x86_32
x11-libs/libdrm abi_x86_32 x11-libs/libdrm abi_x86_32
x11-libs/libpciaccess abi_x86_32
x11-libs/libX11 abi_x86_32 x11-libs/libX11 abi_x86_32
x11-libs/libXau abi_x86_32 x11-libs/libXau abi_x86_32
x11-libs/libxcb abi_x86_32 x11-libs/libxcb abi_x86_32

108
tests/test_configs.py Normal file
View file

@ -0,0 +1,108 @@
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()

View file

@ -0,0 +1,119 @@
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()

131
tests/test_proposals.py Normal file
View file

@ -0,0 +1,131 @@
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()

81
tests/test_release.py Normal file
View file

@ -0,0 +1,81 @@
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()

119
tests/test_restoration.py Normal file
View file

@ -0,0 +1,119 @@
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()

143
tests/test_setup.py Normal file
View file

@ -0,0 +1,143 @@
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()

92
tools/audit-packages.py Normal file
View file

@ -0,0 +1,92 @@
#!/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())

View file

@ -183,16 +183,10 @@ def capture_fish():
def capture_kitty(): def capture_kitty():
def portable_profile(text, name):
text = re.sub(r'(?m)^linux_display_server\s+wayland\s*$',
'linux_display_server auto', text)
text = re.sub(r'(?m)^shell\s+.*\n?', '', text)
if name == 'kitty-common.conf':
text = text.rstrip() + '\n\nshell fish\n'
return text
for name in ['kitty.conf', 'kitty-main.conf', 'kitty-bright.conf', 'kitty-common.conf']: for name in ['kitty.conf', 'kitty-main.conf', 'kitty-bright.conf', 'kitty-common.conf']:
copy('kitty', '.config/kitty/' + name, copy('kitty', '.config/kitty/' + name,
lambda t: portable_profile(t, name)) lambda t: re.sub(r'(?m)^linux_display_server\s+wayland\s*$',
'linux_display_server auto', t))
def capture_neovim(): def capture_neovim():

View file

@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Check portable code and the shipped assets without applying the installer."""
import gzip
import hashlib
import importlib.util
import io
import json
import os
import subprocess
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
def sha(data):
return hashlib.sha256(data).hexdigest()
def check_assets():
spec = importlib.util.spec_from_file_location('launcher', REPO / 'lib/launcher.py')
launcher = importlib.util.module_from_spec(spec)
spec.loader.exec_module(launcher)
root = REPO / 'assets/bin'
metadata = json.loads((root / 'tui.json').read_text())
if metadata['version'] != (REPO / 'VERSION').read_text().strip():
raise RuntimeError('TUI version does not match VERSION; rebuild the release bundle.')
if metadata['source_sha256'] != launcher.source_hash(REPO):
raise RuntimeError('TUI source changed; rebuild the release bundle.')
for binary in metadata['binaries'].values():
path = root / binary['file']
if path.parent.resolve() != root.resolve():
raise RuntimeError('Invalid TUI archive path.')
compressed = path.read_bytes()
if sha(compressed) != binary['gzip_sha256']:
raise RuntimeError('TUI archive checksum mismatch.')
size = binary['size']
if not isinstance(size, int) or not 0 < size <= 64 * 1024 * 1024:
raise RuntimeError('Invalid TUI size.')
with gzip.GzipFile(fileobj=io.BytesIO(compressed)) as stream:
executable = stream.read(size + 1)
if len(executable) != size or sha(executable) != binary['sha256']:
raise RuntimeError('TUI executable checksum mismatch.')
line = binary['gzip_sha256'] + ' ' + binary['file']
if line not in (root / 'SHA256SUMS').read_text().splitlines():
raise RuntimeError('SHA256SUMS does not match the TUI archive.')
for name, entry in json.loads((REPO / 'assets/editor-themes.json').read_text()).items():
directory = REPO / entry['root']
if not directory.resolve().is_relative_to(REPO):
raise RuntimeError('Invalid editor asset directory.')
actual = {path.relative_to(directory).as_posix(): sha(path.read_bytes())
for path in sorted(directory.rglob('*')) if path.is_file()}
if actual != entry['sha256']:
raise RuntimeError('Editor asset fingerprints changed: ' + name)
print('Prebuilt TUI and editor asset fingerprints match.', flush=True)
def main():
subprocess.run(['python3', 'tools/check-private.py'], cwd=REPO, check=True)
check_assets()
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', '-buildvcs=false', '-o', os.devnull, './cmd/dots'], cwd=REPO, check=True)
print('Portable CI checks passed; no installer actions were applied.', flush=True)
if __name__ == '__main__':
main()

View file

@ -1 +0,0 @@
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl

View file

@ -1,64 +0,0 @@
#!/usr/bin/env python3
"""Publish Forgejo branches/tags to GitHub without deleting or forcing refs."""
import argparse
import os
import shlex
import subprocess
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
REPO = Path(__file__).resolve().parents[2]
SOURCE = 'https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git'
GITHUB_HOST = 'github.com'
GITHUB_REPOSITORY = 'Fi3w0/Fiw-Gentoo-Dots'
TARGET = f'ssh://git@{GITHUB_HOST}/{GITHUB_REPOSITORY}.git'
def mirror(source=SOURCE, target=TARGET, key=None, source_ref=None, source_sha=None):
environment = dict(os.environ, GIT_TERMINAL_PROMPT='0')
environment.pop('GH_MIRROR_SSH_KEY', None)
with tempfile.TemporaryDirectory(prefix='fiw-github-mirror-') as temporary:
root = Path(temporary)
remote = urlsplit(target)
if remote.scheme == 'ssh' and remote.hostname == GITHUB_HOST:
if not key or not key.strip():
raise RuntimeError('Add the GH_MIRROR_SSH_KEY repository secret in Forgejo before mirroring.')
identity = root / 'identity'
identity.write_text(key.strip() + '\n')
identity.chmod(0o600)
environment['GIT_SSH_COMMAND'] = shlex.join([
'ssh', '-F', '/dev/null', '-i', str(identity),
'-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
'-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(REPO / 'tools/ci/github_known_hosts')])
snapshot = root / 'source.git'
subprocess.run(['git', 'clone', '--bare', '--no-local', source, str(snapshot)], env=environment, check=True)
if source_ref:
if not source_ref.startswith(('refs/heads/', 'refs/tags/')):
raise RuntimeError('Mirror source must be a branch or tag ref.')
subprocess.run(['git', 'check-ref-format', source_ref], env=environment, check=True)
actual = subprocess.run(['git', '-C', str(snapshot), 'rev-parse', '--verify', source_ref + '^{commit}'], env=environment, text=True, capture_output=True)
reviewed = subprocess.run(['git', '-C', str(snapshot), 'rev-parse', '--verify', (source_sha or '') + '^{commit}'], env=environment, text=True, capture_output=True)
if actual.returncode or reviewed.returncode or actual.stdout != reviewed.stdout:
print('Source ref changed or was removed since this CI run; a newer run can publish it.')
return
subprocess.run(['git', '-C', str(snapshot), 'push', '--atomic', target,
'refs/heads/*:refs/heads/*', 'refs/tags/*:refs/tags/*'], env=environment, check=True)
print('Forgejo branches and tags published to GitHub.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', default=SOURCE)
parser.add_argument('--target', default=TARGET)
parser.add_argument('--key-file', type=Path, help='Local deploy key for an explicit manual sync')
args = parser.parse_args()
key = args.key_file.read_text() if args.key_file else os.environ.get('GH_MIRROR_SSH_KEY')
mirror(args.source, args.target, key, os.environ.get('MIRROR_SOURCE_REF'), os.environ.get('MIRROR_SOURCE_SHA'))
if __name__ == '__main__':
try:
main()
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
raise SystemExit(str(error))