Compare commits
No commits in common. "main" and "v0.1.2" have entirely different histories.
47 changed files with 5127 additions and 5120 deletions
|
|
@ -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
|
|
||||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
|
|
@ -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
189
README.md
|
|
@ -9,111 +9,174 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
[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.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
My running desktop. Wallpapers and panels stay local; the saved Fastfetch
|
|
||||||
preset uses the built-in Gentoo logo.
|
|
||||||
|
|
||||||
### Installer
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 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
207
cmd/dots/main_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
27
docs/boot.md
27
docs/boot.md
|
|
@ -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.
|
||||||
|
|
|
||||||
63
docs/ci.md
63
docs/ci.md
|
|
@ -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
29
docs/package-audit.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
97
lib/boot.py
97
lib/boot.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
92
lib/rice.py
92
lib/rice.py
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
54
optional/fiw-limine-splice
Executable 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"
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# Installed as /etc/modules-load.d/fiw-dots-bbr.conf (Fiw's Ryzen preset)
|
|
||||||
tcp_bbr
|
|
||||||
3
packages/audit-exceptions.json
Normal file
3
packages/audit-exceptions.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
108
tests/test_configs.py
Normal 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()
|
||||||
119
tests/test_devices_capture.py
Normal file
119
tests/test_devices_capture.py
Normal 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
131
tests/test_proposals.py
Normal 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
81
tests/test_release.py
Normal 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
119
tests/test_restoration.py
Normal 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
143
tests/test_setup.py
Normal 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
92
tools/audit-packages.py
Normal 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())
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
|
|
||||||
|
|
@ -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))
|
|
||||||
Loading…
Add table
Reference in a new issue