Complete optional setup and boot restoration workflow
This commit is contained in:
parent
09503bf948
commit
8d59cc4f06
15 changed files with 1204 additions and 120 deletions
24
README.md
24
README.md
|
|
@ -1,5 +1,7 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/images/Gentoo.png" alt="Gentoo logo" width="110" />
|
||||||
|
|
||||||
# Fiw-Gentoo-Dots
|
# Fiw-Gentoo-Dots
|
||||||
|
|
||||||
**My Gentoo setup, ready to make home feel like home again.**
|
**My Gentoo setup, ready to make home feel like home again.**
|
||||||
|
|
@ -29,7 +31,7 @@ anything is applied.
|
||||||
- **Keep your edits:** conflict prompts, backups and `.new` proposals on updates.
|
- **Keep your edits:** conflict prompts, backups and `.new` proposals on updates.
|
||||||
|
|
||||||
**Work in progress:** configuration restoration and the TUI are ready for review.
|
**Work in progress:** configuration restoration and the TUI are ready for review.
|
||||||
Package installation needs a supervised first run on a fresh Gentoo system.
|
Package and boot execution need a supervised first run on a fresh Gentoo system.
|
||||||
The existing installation has not been changed by creating this repo.
|
The existing installation has not been changed by creating this repo.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
@ -41,10 +43,12 @@ cd Fiw-Gentoo-Dots
|
||||||
```
|
```
|
||||||
|
|
||||||
The Bubble Tea interface offers a preset, package sections, individual app
|
The Bubble Tea interface offers a preset, package sections, individual app
|
||||||
configs, kernel/bootloader choices, extras, and a final preview. Enter at the
|
configs, kernel/bootloader choices, individual Flatpaks, optional services and
|
||||||
preview saves a selection without applying it. `a` restores configs; `i`
|
a final preview. Enter saves without applying. `r` runs the full restore;
|
||||||
starts the package installation workflow with sudo. Go 1.24+ builds the TUI;
|
`a` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
|
||||||
Python 3.11+ runs the backend. Go dependencies are recorded in go.mod/go.sum.
|
`v` enables selected services and `b` deploys the selected bootloader.
|
||||||
|
Go 1.24+ builds the TUI; Python 3.11+ runs the backend. Go dependencies are
|
||||||
|
recorded in go.mod/go.sum. Saved selections are reused on subsequent launches.
|
||||||
|
|
||||||
For a preview without building the TUI:
|
For a preview without building the TUI:
|
||||||
|
|
||||||
|
|
@ -85,9 +89,11 @@ CachyOS 7.2.7-1 patch. Refresh it explicitly when updating the snapshot.
|
||||||
| [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities |
|
| [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities |
|
||||||
|
|
||||||
Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not
|
Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not
|
||||||
require Btrfs tools. LocalSend is an optional Flatpak in the apps section.
|
require Btrfs tools. LocalSend and Sober are individually optional Flatpaks
|
||||||
|
in the TUI, grouped with apps and gaming respectively.
|
||||||
Limine and GRUB are optional alternatives; retaining the current loader is
|
Limine and GRUB are optional alternatives; retaining the current loader is
|
||||||
the default. Kernel choice is independent of bootloader choice.
|
the default. Kernel choice is independent of bootloader choice. Deployment
|
||||||
|
previews the target ESP and firmware choices; see [boot setup](docs/boot.md).
|
||||||
|
|
||||||
## Configs
|
## Configs
|
||||||
|
|
||||||
|
|
@ -136,6 +142,10 @@ python3 -m unittest discover -s tests
|
||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Missing selected overlays are staged before package resolution. Optional
|
||||||
|
service presets enable audio, networking, Bluetooth and power profiles for
|
||||||
|
the next boot/login. See [setup](docs/setup.md) and the [helper list](docs/scripts.md).
|
||||||
|
|
||||||
Review captures before committing. `local/` and built binaries are ignored.
|
Review captures before committing. `local/` and built binaries are ignored.
|
||||||
This repo starts with fresh history. Third-party theme/cursor assets retain
|
This repo starts with fresh history. Third-party theme/cursor assets retain
|
||||||
their licence notices; overlay ebuilds retain their original notices.
|
their licence notices; overlay ebuilds retain their original notices.
|
||||||
|
|
|
||||||
BIN
assets/images/Arch.png
Normal file
BIN
assets/images/Arch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
assets/images/Gentoo.png
Normal file
BIN
assets/images/Gentoo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
140
cmd/dots/main.go
140
cmd/dots/main.go
|
|
@ -22,15 +22,25 @@ type selection struct {
|
||||||
Extras []string `json:"extras"`
|
Extras []string `json:"extras"`
|
||||||
Firefox bool `json:"firefox_privacy"`
|
Firefox bool `json:"firefox_privacy"`
|
||||||
Spotify bool `json:"spotify_custom"`
|
Spotify bool `json:"spotify_custom"`
|
||||||
|
Flatpaks []string `json:"flatpaks"`
|
||||||
|
Services []string `json:"services"`
|
||||||
}
|
}
|
||||||
type config struct {
|
type config struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
}
|
}
|
||||||
|
type setupOption struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
type catalog struct {
|
type catalog struct {
|
||||||
Groups []string `json:"groups"`
|
Groups []string `json:"groups"`
|
||||||
Configs map[string]config `json:"configs"`
|
Configs map[string]config `json:"configs"`
|
||||||
Extras map[string]string `json:"extras"`
|
Extras map[string]string `json:"extras"`
|
||||||
|
Flatpaks map[string]setupOption `json:"flatpaks"`
|
||||||
|
Services map[string]setupOption `json:"services"`
|
||||||
Stock selection `json:"stock"`
|
Stock selection `json:"stock"`
|
||||||
Ryzen selection `json:"ryzen"`
|
Ryzen selection `json:"ryzen"`
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +112,21 @@ func (m *model) prepare() {
|
||||||
m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)})
|
m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)})
|
||||||
}
|
}
|
||||||
m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify})
|
m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify})
|
||||||
|
case 4:
|
||||||
|
flatpaks := make([]string, 0, len(m.catalog.Flatpaks))
|
||||||
|
for id := range m.catalog.Flatpaks {
|
||||||
|
flatpaks = append(flatpaks, id)
|
||||||
|
}
|
||||||
|
sort.Strings(flatpaks)
|
||||||
|
for _, id := range flatpaks {
|
||||||
|
option := m.catalog.Flatpaks[id]
|
||||||
|
m.rows = append(m.rows, row{"flatpak:" + id, "Flatpak: " + option.Label + " (" + option.Group + ")", contains(m.selection.Flatpaks, id)})
|
||||||
|
}
|
||||||
|
for _, id := range []string{"audio", "network", "bluetooth", "power"} {
|
||||||
|
if option, ok := m.catalog.Services[id]; ok {
|
||||||
|
m.rows = append(m.rows, row{"service:" + id, option.Label, contains(m.selection.Services, id)})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (m *model) remember() {
|
func (m *model) remember() {
|
||||||
|
|
@ -155,6 +180,19 @@ func (m *model) remember() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.selection.Configs = configNames
|
m.selection.Configs = configNames
|
||||||
|
case 4:
|
||||||
|
m.selection.Flatpaks, m.selection.Services = []string{}, []string{}
|
||||||
|
for _, r := range m.rows {
|
||||||
|
if !r.checked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(r.id, "flatpak:") {
|
||||||
|
m.selection.Flatpaks = append(m.selection.Flatpaks, strings.TrimPrefix(r.id, "flatpak:"))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(r.id, "service:") {
|
||||||
|
m.selection.Services = append(m.selection.Services, strings.TrimPrefix(r.id, "service:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (m model) makePreview() tea.Cmd {
|
func (m model) makePreview() tea.Cmd {
|
||||||
|
|
@ -195,7 +233,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.stage == 4 {
|
if m.stage == 5 {
|
||||||
switch key {
|
switch key {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.scroll > 0 {
|
if m.scroll > 0 {
|
||||||
|
|
@ -208,7 +246,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case "esc":
|
case "esc":
|
||||||
m.stage--
|
m.stage--
|
||||||
m.prepare()
|
m.prepare()
|
||||||
case "enter", "s", "a", "i":
|
case "enter", "s", "a", "i", "f", "v", "b", "r":
|
||||||
if m.errorText == "" {
|
if m.errorText == "" {
|
||||||
m.done = true
|
m.done = true
|
||||||
m.action = key
|
m.action = key
|
||||||
|
|
@ -267,7 +305,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
m.remember()
|
m.remember()
|
||||||
m.stage++
|
m.stage++
|
||||||
if m.stage == 4 {
|
if m.stage == 5 {
|
||||||
m.loading = true
|
m.loading = true
|
||||||
m.errorText = ""
|
m.errorText = ""
|
||||||
return m, m.makePreview()
|
return m, m.makePreview()
|
||||||
|
|
@ -287,13 +325,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
const purple = "\033[38;2;157;143;217m"
|
const purple = "\033[38;2;157;143;217m"
|
||||||
const reset = "\033[0m"
|
const reset = "\033[0m"
|
||||||
titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Final preview"}
|
titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Flatpaks and optional services", "Final preview"}
|
||||||
text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n"
|
text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n"
|
||||||
budget := m.height - 10
|
budget := m.height - 10
|
||||||
if budget < 5 {
|
if budget < 5 {
|
||||||
budget = 5
|
budget = 5
|
||||||
}
|
}
|
||||||
if m.stage == 4 {
|
if m.stage == 5 {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return text + " Preparing preview…\n"
|
return text + " Preparing preview…\n"
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +343,7 @@ func (m model) View() string {
|
||||||
for _, line := range lines[m.scroll:end] {
|
for _, line := range lines[m.scroll:end] {
|
||||||
text += " " + line + "\n"
|
text += " " + line + "\n"
|
||||||
}
|
}
|
||||||
text += "\n ↑/↓ scroll · Esc back · Enter save plan · a restore configs · i install packages\n q cancel\n"
|
text += "\n ↑/↓ scroll · Esc back · Enter save · r full restore · a configs · i packages\n f Flatpaks · v services · b bootloader · q cancel\n"
|
||||||
if m.errorText != "" {
|
if m.errorText != "" {
|
||||||
text += " " + m.errorText + "\n"
|
text += " " + m.errorText + "\n"
|
||||||
}
|
}
|
||||||
|
|
@ -355,6 +393,14 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
m := model{repo: repo, catalog: c, selection: c.Stock, height: 28}
|
m := model{repo: repo, catalog: c, selection: c.Stock, height: 28}
|
||||||
|
savedPath := filepath.Join(repo, "local", "selection.json")
|
||||||
|
if saved, readErr := os.ReadFile(savedPath); readErr == nil {
|
||||||
|
var previous selection
|
||||||
|
if json.Unmarshal(saved, &previous) == nil && (previous.Profile == "stock" || previous.Profile == "fiw-ryzen") {
|
||||||
|
m.selection = previous
|
||||||
|
m.stage = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
m.prepare()
|
m.prepare()
|
||||||
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
|
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -377,27 +423,75 @@ func main() {
|
||||||
}
|
}
|
||||||
fmt.Println(final.preview)
|
fmt.Println(final.preview)
|
||||||
fmt.Println("\nSaved selection:", path)
|
fmt.Println("\nSaved selection:", path)
|
||||||
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path}
|
run := func(action string, root bool) error {
|
||||||
switch final.action {
|
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, action}
|
||||||
case "a":
|
|
||||||
args = append(args, "--apply-configs")
|
|
||||||
case "i":
|
|
||||||
args = append([]string{"python3"}, args...)
|
|
||||||
args = append(args, "--install-packages")
|
|
||||||
default:
|
|
||||||
fmt.Println("Review the setup notes in docs/setup.md before applying.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
program := "python3"
|
program := "python3"
|
||||||
if final.action == "i" {
|
if root {
|
||||||
program = "sudo"
|
program = "sudo"
|
||||||
|
args = append([]string{"python3"}, args...)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(program, args...)
|
cmd := exec.Command(program, args...)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||||
cmd.Stdout = os.Stdout
|
return cmd.Run()
|
||||||
cmd.Stderr = os.Stderr
|
}
|
||||||
if err := cmd.Run(); err != nil {
|
services := func() error {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
system, user := false, false
|
||||||
|
for _, id := range final.selection.Services {
|
||||||
|
if c.Services[id].Scope == "system" {
|
||||||
|
system = true
|
||||||
|
}
|
||||||
|
if c.Services[id].Scope == "user" {
|
||||||
|
user = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if system {
|
||||||
|
if err := run("--enable-services", true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user {
|
||||||
|
return run("--enable-services", false)
|
||||||
|
}
|
||||||
|
if !system {
|
||||||
|
fmt.Println("No services selected.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var actionErr error
|
||||||
|
switch final.action {
|
||||||
|
case "a":
|
||||||
|
actionErr = run("--apply-configs", false)
|
||||||
|
case "i":
|
||||||
|
actionErr = run("--install-packages", true)
|
||||||
|
case "f":
|
||||||
|
actionErr = run("--install-flatpaks", false)
|
||||||
|
case "v":
|
||||||
|
actionErr = services()
|
||||||
|
case "b":
|
||||||
|
actionErr = run("--deploy-bootloader", true)
|
||||||
|
case "r":
|
||||||
|
actionErr = run("--check-configs", false)
|
||||||
|
if actionErr == nil && (len(final.selection.Groups) > 0 || len(final.selection.Extras) > 0 || final.selection.Bootloader != "keep" || len(final.selection.Flatpaks) > 0 || len(final.selection.Services) > 0) {
|
||||||
|
actionErr = run("--install-packages", true)
|
||||||
|
}
|
||||||
|
if actionErr == nil && len(final.selection.Flatpaks) > 0 {
|
||||||
|
actionErr = run("--install-flatpaks", false)
|
||||||
|
}
|
||||||
|
if actionErr == nil && len(final.selection.Services) > 0 {
|
||||||
|
actionErr = services()
|
||||||
|
}
|
||||||
|
if actionErr == nil && len(final.selection.Configs) > 0 {
|
||||||
|
actionErr = run("--apply-configs", false)
|
||||||
|
}
|
||||||
|
if actionErr == nil && final.selection.Bootloader != "keep" {
|
||||||
|
actionErr = run("--deploy-bootloader", true)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Println("Saved. Choose an action in the TUI or use the commands in docs/setup.md.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actionErr != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, actionErr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,19 @@ func TestConfigsSelectableWithoutPackageGroups(t *testing.T) {
|
||||||
t.Fatal("configs were hidden or deselected by package choices")
|
t.Fatal("configs were hidden or deselected by package choices")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetupCheckboxesSurviveFinalPreview(t *testing.T) {
|
||||||
|
m := model{stage: 4, catalog: catalog{
|
||||||
|
Flatpaks: map[string]setupOption{"org.vinegarhq.Sober": {Label: "Sober", Group: "gaming"}},
|
||||||
|
Services: map[string]setupOption{"audio": {Label: "Audio", Scope: "user"}},
|
||||||
|
}, selection: selection{}}
|
||||||
|
m.prepare()
|
||||||
|
for i := range m.rows {
|
||||||
|
m.rows[i].checked = true
|
||||||
|
}
|
||||||
|
next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
result := next.(model)
|
||||||
|
if result.stage != 5 || command == nil || !contains(result.selection.Flatpaks, "org.vinegarhq.Sober") || !contains(result.selection.Services, "audio") {
|
||||||
|
t.Fatalf("setup selections lost before preview: %+v", result.selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,13 @@ theme metadata and colour-scheme notices. It still scans those files for
|
||||||
other private-data patterns. A changed fingerprint requires review before
|
other private-data patterns. A changed fingerprint requires review before
|
||||||
updating tools/public-attribution.json. The fingerprinted upstream kernel
|
updating tools/public-attribution.json. The fingerprinted upstream kernel
|
||||||
patch similarly retains public source/test data rather than being rewritten.
|
patch similarly retains public source/test data rather than being rewritten.
|
||||||
|
|
||||||
|
## Distribution logos
|
||||||
|
|
||||||
|
The user-supplied Gentoo and Arch images are kept in `assets/images`. The
|
||||||
|
Gentoo image is used in the README; the Arch image is retained as supplied.
|
||||||
|
These distribution marks are separate from this project's original MIT code.
|
||||||
|
Upstream references: [Gentoo artwork](https://www.gentoo.org/inside-gentoo/artwork/)
|
||||||
|
and [name/logo guidelines](https://www.gentoo.org/inside-gentoo/foundation/name-logo-guidelines.html);
|
||||||
|
[Arch artwork](https://archlinux.org/art/) and
|
||||||
|
[trademark policy](https://terms.archlinux.org/docs/trademark-policy/).
|
||||||
|
|
|
||||||
96
docs/boot.md
96
docs/boot.md
|
|
@ -1,20 +1,84 @@
|
||||||
# Optional bootloader choices
|
# Optional bootloader deployment
|
||||||
|
|
||||||
`keep` is the default. `limine` and `grub` select separate package lists.
|
`keep` is the default. Limine and GRUB have separate package choices; kernel
|
||||||
The draft does not install firmware entries or replace the active bootloader.
|
selection remains independent. Selecting a loader does not itself deploy it.
|
||||||
Target-specific deployment still needs implementation and a reviewed target
|
Use the final preview's **b** action or include it in **r** full restore.
|
||||||
ESP/root layout. Selecting a kernel does not select a bootloader.
|
|
||||||
|
|
||||||
GRUB integration must use the target system's installkernel configuration
|
## Preview and apply
|
||||||
and generate that system's grub.cfg. Limine integration must derive root and
|
|
||||||
ESP identifiers on the target and produce the selected custom/generic entries.
|
|
||||||
Fiw's current dual-boot paths and UUIDs do not belong in generic templates.
|
|
||||||
|
|
||||||
The retained `optional/fiw-limine-splice` refreshes the Gentoo block in an
|
```sh
|
||||||
existing Limine configuration and preserves other entries. It is specific
|
./install --selection local/selection.json --boot-plan
|
||||||
to the current marker/snippet design and is not installed by the default
|
sudo ./install --selection local/selection.json --deploy-bootloader
|
||||||
rice preset or by the GRUB variant.
|
# Specify a mounted ESP when automatic discovery does not find yours:
|
||||||
|
sudo ./install --selection local/selection.json --deploy-bootloader --esp /boot/efi
|
||||||
|
```
|
||||||
|
|
||||||
`optional/fiw-cachy-patch` supports the custom kernel, not a bootloader. It
|
Discovery checks mounted `/efi`, `/boot/efi` and `/boot` candidates for the GPT
|
||||||
generates a CachyOS patch and updates the version guard/name. It requires
|
ESP type and FAT filesystem. The plan shows the selected partition, root
|
||||||
root and matching upstream source releases; review before running.
|
filesystem, kernel arguments, installed kernel/initramfs candidates and
|
||||||
|
proposed loader config or commands. UUIDs are derived on the target and kept
|
||||||
|
in its local state; they are not shipped in the repo.
|
||||||
|
|
||||||
|
Firmware registration has three choices:
|
||||||
|
|
||||||
|
| Choice | Effect |
|
||||||
|
|---|---|
|
||||||
|
| keep | Leave current firmware entries and BootOrder unchanged |
|
||||||
|
| add | Create an entry without adding it to BootOrder |
|
||||||
|
| default | Register the selected loader first in BootOrder |
|
||||||
|
|
||||||
|
The final deployment confirmation follows the target and firmware preview.
|
||||||
|
Existing matching firmware entries are reused when their partition and EFI
|
||||||
|
path match. Other entries are retained. No reboot is performed.
|
||||||
|
|
||||||
|
## Limine
|
||||||
|
|
||||||
|
The loader goes to `EFI/Fiw-Gentoo/BOOTX64.EFI`, with `limine.conf` beside it.
|
||||||
|
Versioned kernels and initramfs files are copied below that directory so
|
||||||
|
Limine can read them from the ESP. Custom Fiw/Cachy kernels appear first,
|
||||||
|
followed by generic kernels, including an installed binary fallback.
|
||||||
|
|
||||||
|
A marked block contains the generated Gentoo entries. Entries outside that
|
||||||
|
block are preserved. Locally edited managed entries receive a `.new` proposal
|
||||||
|
instead of being overwritten. ESP free space is checked before copying kernel
|
||||||
|
artifacts. The protocol and paths follow the upstream
|
||||||
|
[Limine configuration reference](https://github.com/limine-bootloader/limine/blob/v12.x/CONFIG.md).
|
||||||
|
|
||||||
|
## GRUB
|
||||||
|
|
||||||
|
`grub-install` uses `--target=x86_64-efi`, a dedicated `Fiw-Gentoo` EFI ID,
|
||||||
|
`/boot/fiw-gentoo` as its boot directory and `--no-nvram`. Firmware changes are
|
||||||
|
handled separately by the selected registration choice.
|
||||||
|
|
||||||
|
The generated menu lives in `/boot/fiw-gentoo/grub/grub.cfg`. A temporary output
|
||||||
|
is generated before replacing it. If the previous menu was edited locally,
|
||||||
|
the new menu becomes a `.new` proposal. Existing `/etc/default/grub` settings
|
||||||
|
are used by `grub-mkconfig`.
|
||||||
|
|
||||||
|
## Kernel updates and backups
|
||||||
|
|
||||||
|
Deployment installs a standalone refresh helper at
|
||||||
|
`/usr/local/libexec/fiw-dots-boot`, a systemd kernel-install hook and a
|
||||||
|
traditional Gentoo installkernel post-install hook. The active installkernel
|
||||||
|
configuration uses the compat layout and Dracut. Missing initramfs files are
|
||||||
|
generated when needed. Each refresh verifies the ESP is still mounted and
|
||||||
|
updates only the selected loader's managed menu.
|
||||||
|
|
||||||
|
The systemd remove hook excludes the removed kernel from its generated menu.
|
||||||
|
The legacy post-install hook refreshes after additions. Deployment saves
|
||||||
|
local boot state under `/etc/fiw-gentoo-dots/boot.json`; backup files and reports
|
||||||
|
live under `/var/lib/Fiw-Gentoo-Dots`. No existing disk partition is formatted.
|
||||||
|
|
||||||
|
Automatic deployment currently supports amd64 UEFI, GPT ESPs, plain ext4/Btrfs
|
||||||
|
roots and unsigned boot. Encrypted, UKI-only, BIOS and signing workflows can
|
||||||
|
keep their existing loader. Secure Boot being active stops unsigned deployment
|
||||||
|
before changes. Full boot execution on a fresh installation remains unverified.
|
||||||
|
|
||||||
|
## Retained helpers
|
||||||
|
|
||||||
|
`optional/fiw-limine-splice` is the earlier helper for Fiw's marker/snippet
|
||||||
|
layout. It is retained for manual use and is not installed by this deployment
|
||||||
|
or by the GRUB variant. The new refresh helper manages its own separate config.
|
||||||
|
|
||||||
|
`optional/fiw-cachy-patch` refreshes the custom kernel patch; it does not select
|
||||||
|
a bootloader. See the [helper list](scripts.md) before using legacy helpers.
|
||||||
|
|
|
||||||
29
docs/scripts.md
Normal file
29
docs/scripts.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Helpers and what they do
|
||||||
|
|
||||||
|
| Helper | Purpose | How it is used |
|
||||||
|
|---|---|---|
|
||||||
|
| `install` | Builds/opens the Bubble Tea chooser, or forwards CLI options to the Python backend | Main entry point |
|
||||||
|
| `lib/rice.py` | Validates selections, previews plans, restores configs and installs Portage packages | Called by the entry point/TUI |
|
||||||
|
| `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend |
|
||||||
|
| `lib/boot.py` | Detects the target ESP/root, previews/deploys Limine or GRUB, refreshes kernel menus | Boot action; copied as `fiw-dots-boot` on deployment |
|
||||||
|
| `fiw-shot` | Captures a region or monitor using Spectacle, copies it to the clipboard and offers Save/Edit through a notification | Installed with KDE shortcuts; `region` or `screen` argument |
|
||||||
|
| `tools/capture.py` | Recaptures the explicit preference allowlist without copying full personal profiles | Maintenance command; review the diff |
|
||||||
|
| `tools/shortcuts.py` | Regenerates the readable shortcut reference from captured repo files | Run after changing/capturing shortcuts |
|
||||||
|
| `tools/check-private.py` | Scans publishable files for personal paths, identifiers and credentials; keeps fingerprinted upstream attribution | Run before committing |
|
||||||
|
| `optional/fiw-cachy-patch` | Downloads matching vanilla/CachyOS kernel sources, generates a patch and adjusts the installed guard/name config | Explicit manual root operation; not part of ordinary restoration |
|
||||||
|
| `optional/fiw-limine-splice` | Replaces the old Gentoo snippet block in an existing Limine menu and orders its entries | Explicit manual use with an ESP argument; specific to the earlier snippet design |
|
||||||
|
| `optional/spotify/spotify-patch` | Runs the retained SpotX/Spicetify customization workflow for the Gentoo Spotify client | Explicit optional user operation; stock Spotify is the default |
|
||||||
|
| `optional/spotify/spicetify-snapshotfix` | Copies missing Marketplace fragments into the captured Spotify UI snapshot format | Called by the optional customization workflow |
|
||||||
|
|
||||||
|
The kernel refresh helper installed on a target runs with `--refresh`. Its
|
||||||
|
kernel hooks are created only when boot deployment is explicitly confirmed;
|
||||||
|
they are not enabled by the default `keep` choice.
|
||||||
|
|
||||||
|
The older Cachy patch helper predates the snapshot guard shipped by this
|
||||||
|
repo. It does not update the repo's `snapshot.json`, checksum or version mask;
|
||||||
|
review/update those together when preparing a new supported kernel snapshot.
|
||||||
|
It is retained as a reference, not run automatically.
|
||||||
|
|
||||||
|
For the optional Spotify scripts, see [the setup and risk note](spotify.md).
|
||||||
|
Unofficial Spotify modifications are optional and used at your own risk.
|
||||||
|
This project does not endorse bypassing paid features.
|
||||||
168
docs/setup.md
168
docs/setup.md
|
|
@ -1,83 +1,131 @@
|
||||||
# Setup and current draft boundaries
|
# Restore the setup
|
||||||
|
|
||||||
Start with an installed amd64 Gentoo system using systemd and a multilib
|
Start with an installed amd64 Gentoo system using systemd and a multilib
|
||||||
Plasma profile. This repo does not partition disks or install the stage3.
|
Plasma profile, with Git and Python 3.11+ available. Go 1.24+ builds the TUI.
|
||||||
Plasma Login Manager currently requires systemd. It is the default greeter
|
This repo does not partition disks or install a stage3.
|
||||||
when installing the KDE section.
|
|
||||||
|
|
||||||
1. Run `./install`, make selections and review the final preview.
|
## Terminal interface
|
||||||
2. Save the selection. It lives in the ignored `local/selection.json`.
|
|
||||||
3. Export for inspection if desired:
|
|
||||||
`./install --selection local/selection.json --export local/stage`.
|
|
||||||
4. Install packages via the TUI or
|
|
||||||
`sudo ./install --selection local/selection.json --install-packages`.
|
|
||||||
Resolution happens in a temporary Portage config before live files change.
|
|
||||||
Missing binaries prompt compile/skip; final installation requires confirmation.
|
|
||||||
5. Apply user configs as the target user:
|
|
||||||
`./install --selection local/selection.json --apply-configs`.
|
|
||||||
Log out of Plasma and use a TTY for KDE configs; running KDE can overwrite
|
|
||||||
edited config files. Portable app configs can be restored separately.
|
|
||||||
|
|
||||||
For updates, review the Git changes, then use the saved selection with
|
Run `./install`. Choose a preset, package sections, individual configs,
|
||||||
`--apply-configs --update`. There is no automatic git pull or upstream upgrade.
|
kernel/bootloader options, Flatpaks and optional services. Previous selections
|
||||||
Backups and reports live under `~/.local/state/Fiw-Gentoo-Dots`. Package reports
|
are loaded from the ignored `local/selection.json`; press Esc to return to
|
||||||
and system backups live under `/var/lib/Fiw-Gentoo-Dots`.
|
preset selection when you want to change profiles.
|
||||||
|
|
||||||
## Explicit setup steps
|
At the final preview:
|
||||||
|
|
||||||
Root package installation enables `plasmalogin.service` for selected KDE
|
| Key | Action |
|
||||||
installs without restarting the running greeter. After the desired packages
|
|---|---|
|
||||||
are installed, enable the services you use:
|
| Enter / s | Save the selection without applying it |
|
||||||
|
| r | Full restore: packages, Flatpaks, services, configs, selected bootloader |
|
||||||
|
| i | Install Portage packages |
|
||||||
|
| f | Install selected user Flatpaks |
|
||||||
|
| v | Enable selected services |
|
||||||
|
| a | Restore selected user configs |
|
||||||
|
| b | Preview and deploy the selected bootloader |
|
||||||
|
|
||||||
|
Full restore checks the KDE session before making package changes. Log out of
|
||||||
|
Plasma and run from a TTY when KDE configs are selected. Each operation also
|
||||||
|
has a command for resuming a saved selection:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo systemctl enable NetworkManager bluetooth power-profiles-daemon
|
./install --selection local/selection.json --plan
|
||||||
systemctl --user enable pipewire.socket pipewire-pulse.socket wireplumber.service
|
sudo ./install --selection local/selection.json --install-packages
|
||||||
|
./install --selection local/selection.json --install-flatpaks
|
||||||
|
sudo ./install --selection local/selection.json --enable-services
|
||||||
|
./install --selection local/selection.json --enable-services
|
||||||
|
./install --selection local/selection.json --apply-configs
|
||||||
|
sudo ./install --selection local/selection.json --deploy-bootloader
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker stays disabled until needed. The installer does not reboot or power off.
|
The root service command handles system services; the regular-user command
|
||||||
|
handles PipeWire units. User Flatpaks and user configs are never applied as root.
|
||||||
|
|
||||||
Flatpak apps are listed separately. Add Flathub if absent, then install only
|
## Packages and repositories
|
||||||
the selected categories' apps explicitly, for example:
|
|
||||||
|
Existing GURU and steam-overlay checkouts are reused. Missing overlays needed
|
||||||
|
by the selected package sections are cloned into temporary storage before
|
||||||
|
Portage resolves the plan. Their identity is checked, and they are adopted
|
||||||
|
under `/var/db/repos` only after confirmation. Existing repository checkouts
|
||||||
|
are not automatically synced or replaced.
|
||||||
|
|
||||||
|
Additional source builds prompt **compile or skip**, with compilation
|
||||||
|
recommended. Skipping a dependency also skips requested packages needing it.
|
||||||
|
A package report records requested, skipped and missing packages. There is a
|
||||||
|
final package confirmation before live Portage configuration changes.
|
||||||
|
|
||||||
|
Read-only resolution and an export are available separately:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
./install --selection local/selection.json --check-packages
|
||||||
flatpak install --user flathub org.localsend.localsend_app
|
./install --selection local/selection.json --export local/stage
|
||||||
flatpak install --user flathub org.vinegarhq.Sober
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The independently selectable `fonts` config includes the four used styles of
|
The check may download a missing overlay into its temporary root; it does not
|
||||||
JetBrainsMono Nerd Font Mono from the official Nerd Fonts v3.5.1 release,
|
register that checkout or change the live Portage configuration. The export
|
||||||
with its OFL licence. It works with KDE and portable apps alike. The entire
|
contains proposed files and a plan, not a ready-to-merge repository checkout.
|
||||||
personal font collection is not included. Neovim uses its built-in theme as
|
|
||||||
a fallback when Catppuccin is not installed. Vesktop's selected Midnight
|
Plasma Login Manager is enabled for selected KDE installs. The installer does
|
||||||
theme CSS is captured with a fixed upstream snapshot and its MIT licence.
|
not restart the running greeter, reboot or power off.
|
||||||
|
|
||||||
|
## Flatpaks and services
|
||||||
|
|
||||||
|
LocalSend (`fiw-apps`) and Sober (`gaming`) have individual Flatpak checkboxes.
|
||||||
|
Both start unchecked. Selecting them ensures Flatpak is in the package plan;
|
||||||
|
the separate Flatpak action adds Flathub for the user if absent and installs
|
||||||
|
only missing selected apps. Existing user installations are kept. Failures
|
||||||
|
and skips are reported.
|
||||||
|
|
||||||
|
Audio, NetworkManager, Bluetooth and power profiles have independent service
|
||||||
|
checkboxes, also initially unchecked. Their required packages are added when
|
||||||
|
needed. Enabling a service schedules it for the next boot or user login; the
|
||||||
|
workflow does not start, stop or restart services. Missing units are reported.
|
||||||
|
Docker remains a separate choice outside this service preset.
|
||||||
|
|
||||||
|
## Configs and updates
|
||||||
|
|
||||||
|
Every config is optional and independent of package installation groups.
|
||||||
|
The separate fonts config includes four JetBrainsMono Nerd Font Mono styles.
|
||||||
|
Vesktop's Midnight CSS is included with a fixed upstream snapshot. See the
|
||||||
|
[KDE styling reference](kde-style.md) and [shortcut list](shortcuts.md).
|
||||||
|
|
||||||
|
Review Git changes before pulling updates, then restore your saved selection:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./install --selection local/selection.json --apply-configs --update
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally edited configs remain intact; proposed changes are written to `.new`
|
||||||
|
files. Replacements receive backups. Root Portage file conflicts ask before
|
||||||
|
replacement and receive backups too. Reports and user backups live under
|
||||||
|
`~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under
|
||||||
|
`/var/lib/Fiw-Gentoo-Dots`.
|
||||||
|
|
||||||
## Fiw tools
|
## Fiw tools
|
||||||
|
|
||||||
The `fiw-tools` section includes FiwNode's x86-64 binary release and the
|
The `fiw-tools` section includes FiwNode's binary release and the released
|
||||||
released Apdatifier Gentoo widget, alongside OpenDeck and Music Presence.
|
Apdatifier Gentoo widget, alongside OpenDeck and Music Presence. Apdatifier
|
||||||
Apdatifier installs QML/scripts without compilation; any dependencies needing
|
installs QML/scripts without compilation; source-only dependencies still
|
||||||
source builds still go through the normal compile/skip prompt.
|
require the normal compile/skip choice.
|
||||||
|
|
||||||
FiwNode uses its default configuration. The installer does not copy sound
|
FiwNode uses its default config. Sound libraries and audio device selections
|
||||||
libraries, microphone/output selections or personal app state. Older copies
|
stay local. Copies in `~/.local/bin` can shadow `/usr/bin`; manage old copies
|
||||||
in `~/.local/bin` can shadow the Portage-installed binaries in `/usr/bin`;
|
manually when switching to the package. Its daemon starts on demand.
|
||||||
remove those manually when ready to switch. The daemon starts on demand.
|
|
||||||
|
|
||||||
Apdatifier is available through Plasma's **Add Widgets** menu. Installing it
|
Add Apdatifier through Plasma's **Add Widgets** menu. Installation does not
|
||||||
does not alter panels. An existing user-installed widget can override the
|
alter panels. An existing user-installed widget can override the system copy;
|
||||||
system copy; use `kpackagetool6` to manage it. Its preferences remain local.
|
manage it with `kpackagetool6`. Its preferences stay local.
|
||||||
|
|
||||||
## Remaining work before declaring a fresh install verified
|
## Current scope
|
||||||
|
|
||||||
- Run both presets on a fresh Gentoo test installation. Root package execution
|
Automatic boot deployment supports amd64 UEFI, a mounted GPT ESP, plain ext4
|
||||||
has not been exercised on the working desktop.
|
or Btrfs roots, and unsigned boot. See [boot setup](boot.md) for previews,
|
||||||
- Implement target-specific Limine/GRUB deployment after reviewing the target
|
firmware choices and kernel update integration. Other layouts can keep their
|
||||||
ESP and boot layout. Current variants select packages and provide setup notes.
|
existing loader.
|
||||||
- Refresh the optional TideWM live ebuild branch/dependency recipe against the
|
|
||||||
upstream development version before building it on a new machine.
|
|
||||||
- Repository sync is an explicit prerequisite when GURU or steam-overlay is
|
|
||||||
absent; the draft aborts cleanly on resolution failure rather than guessing.
|
|
||||||
|
|
||||||
Do not publish the private original fiw-gentoo repository. It is the working
|
The optional TideWM live recipe still needs a refresh against its upstream
|
||||||
archive and has different history and host-specific files.
|
development branch. End-to-end package and boot execution on a fresh Gentoo
|
||||||
|
installation remains unverified; development checks used temporary homes,
|
||||||
|
staged Portage configuration and mocked system commands.
|
||||||
|
|
||||||
|
Do not publish the private original fiw-gentoo archive. It has different
|
||||||
|
history and host-specific files.
|
||||||
|
|
|
||||||
431
lib/boot.py
Normal file
431
lib/boot.py
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Preview and deploy a separate amd64 UEFI Limine or GRUB installation."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STATE = Path('/etc/fiw-gentoo-dots/boot.json')
|
||||||
|
MANAGED = Path('/var/lib/Fiw-Gentoo-Dots')
|
||||||
|
BEGIN = '# >>> Fiw-Gentoo-Dots kernels'
|
||||||
|
END = '# <<< Fiw-Gentoo-Dots kernels'
|
||||||
|
EFI_DIRECTORY = 'EFI/Fiw-Gentoo'
|
||||||
|
GRUB_DIRECTORY = Path('/boot/fiw-gentoo')
|
||||||
|
|
||||||
|
|
||||||
|
def mount_info(path, exact=False):
|
||||||
|
result = subprocess.run(['findmnt', '--json', '--mountpoint' if exact else '--target',
|
||||||
|
str(path), '--output', 'TARGET,SOURCE,FSTYPE,UUID,OPTIONS'],
|
||||||
|
text=True, capture_output=True)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError('Not a mounted filesystem: ' + str(path))
|
||||||
|
data = json.loads(result.stdout)['filesystems'][0]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def discover_esp(path=None):
|
||||||
|
candidates = [Path(path)] if path else [Path('/efi'), Path('/boot/efi'), Path('/boot')]
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
info = mount_info(candidate, exact=True)
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
if info['fstype'] not in ('vfat', 'fat', 'fat32'):
|
||||||
|
continue
|
||||||
|
source = re.sub(r'\[.*\]$', '', info['source'])
|
||||||
|
device = Path(source).resolve()
|
||||||
|
partition = Path('/sys/class/block') / device.name / 'partition'
|
||||||
|
if not partition.is_file():
|
||||||
|
continue
|
||||||
|
# Require the GPT ESP type, not an arbitrary mounted FAT data partition.
|
||||||
|
result = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'PARTTYPE', str(device)],
|
||||||
|
text=True, capture_output=True)
|
||||||
|
esp_type = 'c12a7328f81f11d2ba4b00a0c93ec93b' # GPT ESP type, without separators
|
||||||
|
if result.returncode or result.stdout.strip().lower().replace('-', '') != esp_type:
|
||||||
|
continue
|
||||||
|
parent = (Path('/sys/class/block') / device.name).resolve().parent.name
|
||||||
|
identifier = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'PARTUUID', str(device)], text=True, capture_output=True)
|
||||||
|
return {'partuuid': identifier.stdout.strip().lower(), 'path': str(candidate.resolve()), 'device': str(device),
|
||||||
|
'disk': '/dev/' + parent, 'partition': int(partition.read_text())}
|
||||||
|
raise RuntimeError('Select a mounted GPT EFI System Partition with --esp. No ESP was found.')
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_images(boot=Path('/boot'), exclude=None):
|
||||||
|
found = {}
|
||||||
|
for pattern, prefix in [('vmlinuz-*', 'vmlinuz-'), ('kernel-*', 'kernel-')]:
|
||||||
|
for path in boot.glob(pattern):
|
||||||
|
version = path.name[len(prefix):]
|
||||||
|
if path.is_file() and re.fullmatch(r'[A-Za-z0-9_.+-]+', version) and version != exclude:
|
||||||
|
initrds = [boot / ('initramfs-' + version + '.img'), boot / ('initrd-' + version),
|
||||||
|
boot / ('initrd.img-' + version)]
|
||||||
|
found.setdefault(version, {'version': version, 'kernel': str(path),
|
||||||
|
'initrd': next((str(p) for p in initrds if p.is_file()), None)})
|
||||||
|
# systemd BLS installations store a linux/initrd pair below their entry ID.
|
||||||
|
for path in boot.glob('*/*/linux'):
|
||||||
|
version = path.parent.name
|
||||||
|
if path.is_file() and re.fullmatch(r'[A-Za-z0-9_.+-]+', version) and version != exclude:
|
||||||
|
initrd = path.parent / 'initrd'
|
||||||
|
found.setdefault(version, {'version': version, 'kernel': str(path),
|
||||||
|
'initrd': str(initrd) if initrd.is_file() else None})
|
||||||
|
for path in Path('/usr/lib/modules').glob('*/vmlinuz'):
|
||||||
|
version = path.parent.name
|
||||||
|
if boot == Path('/boot') and path.is_file() and version != exclude and re.fullmatch(r'[A-Za-z0-9_.+-]+', version):
|
||||||
|
initrd = boot / ('initramfs-' + version + '.img')
|
||||||
|
found.setdefault(version, {'version': version, 'kernel': str(path),
|
||||||
|
'initrd': str(initrd) if initrd.is_file() else None})
|
||||||
|
versions = subprocess.run(['sort', '-V'], input='\n'.join(found), text=True,
|
||||||
|
capture_output=True, check=True).stdout.splitlines()[::-1]
|
||||||
|
versions.sort(key=lambda v: not any(word in v.lower() for word in ('fiw', 'cachy')))
|
||||||
|
return [found[v] for v in versions]
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_cmdline(root):
|
||||||
|
config = Path('/etc/kernel/cmdline')
|
||||||
|
raw = config.read_text().strip() if config.is_file() else Path('/proc/cmdline').read_text().strip()
|
||||||
|
tokens = shlex.split(raw)
|
||||||
|
tokens = [token for token in tokens if not token.startswith(('BOOT_IMAGE=', 'initrd=', 'root='))]
|
||||||
|
if not root.get('uuid'):
|
||||||
|
raise RuntimeError('Cannot identify the target root filesystem UUID.')
|
||||||
|
tokens.insert(0, 'root=UUID=' + root['uuid'])
|
||||||
|
if root['fstype'] == 'btrfs' and not any(t.startswith('rootflags=') for t in tokens):
|
||||||
|
flags = [flag for flag in root.get('options', '').split(',') if flag.startswith('subvol=')]
|
||||||
|
if flags:
|
||||||
|
tokens.append('rootflags=' + flags[0])
|
||||||
|
text = ' '.join(tokens)
|
||||||
|
if any(char in text for char in ('\n', '\r', '\0')):
|
||||||
|
raise RuntimeError('Invalid kernel command line.')
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def secure_boot():
|
||||||
|
files = list(Path('/sys/firmware/efi/efivars').glob('SecureBoot-*'))
|
||||||
|
return bool(files and len(files[0].read_bytes()) > 4 and files[0].read_bytes()[4] == 1)
|
||||||
|
|
||||||
|
|
||||||
|
def limine_loader():
|
||||||
|
for path in [Path('/usr/share/limine/BOOTX64.EFI'), Path('/usr/share/limine/limine-uefi-cd.bin')]:
|
||||||
|
# A CD image is not an EFI executable; only actual PE EFI files qualify.
|
||||||
|
if path.is_file() and path.open('rb').read(2) == b'MZ':
|
||||||
|
return path
|
||||||
|
for path in Path('/usr/share/limine').rglob('BOOTX64.EFI'):
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
raise RuntimeError('Limine BOOTX64.EFI is missing; install sys-boot/limine with uefi-x86-64.')
|
||||||
|
|
||||||
|
|
||||||
|
def render_limine(images, cmdline):
|
||||||
|
lines = [BEGIN]
|
||||||
|
for image in images:
|
||||||
|
version = image['version']
|
||||||
|
label = 'custom' if any(word in version.lower() for word in ('fiw', 'cachy')) else 'generic'
|
||||||
|
lines += [f'/Gentoo {version} ({label})', ' protocol: linux',
|
||||||
|
f' path: boot():/{EFI_DIRECTORY}/kernels/{version}/vmlinuz',
|
||||||
|
f' module_path: boot():/{EFI_DIRECTORY}/kernels/{version}/initramfs.img',
|
||||||
|
' cmdline: ' + cmdline, '']
|
||||||
|
return '\n'.join(lines + [END]) + '\n'
|
||||||
|
|
||||||
|
|
||||||
|
def managed_block(text):
|
||||||
|
if text.count(BEGIN) != text.count(END) or text.count(BEGIN) > 1:
|
||||||
|
raise RuntimeError('Malformed managed Limine block; preserving the config.')
|
||||||
|
if BEGIN not in text:
|
||||||
|
return None
|
||||||
|
start = text.index(BEGIN)
|
||||||
|
end = text.index(END, start) + len(END)
|
||||||
|
return text[start:end] + '\n'
|
||||||
|
|
||||||
|
|
||||||
|
def merge_limine(text, block):
|
||||||
|
old = managed_block(text)
|
||||||
|
if old is None:
|
||||||
|
return text.rstrip() + '\n\n' + block if text.strip() else 'timeout: 5\n\n' + block
|
||||||
|
return text.replace(old.rstrip('\n'), block.rstrip('\n'), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(path):
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def block_hash(block):
|
||||||
|
return hashlib.sha256(block.encode()).hexdigest() if block else None
|
||||||
|
|
||||||
|
|
||||||
|
def install_config(old):
|
||||||
|
values = {'layout': 'compat', 'initrd_generator': 'dracut', 'uki_generator': 'none'}
|
||||||
|
lines, seen = [], set()
|
||||||
|
for line in old.splitlines():
|
||||||
|
key = line.split('=', 1)[0].strip()
|
||||||
|
if key in values:
|
||||||
|
if key not in seen:
|
||||||
|
lines.append(key + '=' + values[key])
|
||||||
|
seen.add(key)
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
lines += [key + '=' + value for key, value in values.items() if key not in seen]
|
||||||
|
return '\n'.join(lines) + '\n'
|
||||||
|
|
||||||
|
|
||||||
|
def plan(mode, esp=None, exclude=None):
|
||||||
|
if mode == 'keep':
|
||||||
|
return {'mode': 'keep', 'changes': []}
|
||||||
|
if mode not in ('limine', 'grub'):
|
||||||
|
raise ValueError('Unknown bootloader')
|
||||||
|
if platform.machine() not in ('x86_64', 'amd64') or not Path('/sys/firmware/efi').is_dir():
|
||||||
|
raise RuntimeError('Automatic boot deployment supports amd64 UEFI installations.')
|
||||||
|
root = mount_info('/')
|
||||||
|
if root['fstype'] not in ('ext4', 'btrfs') or root['source'].startswith('/dev/mapper/'):
|
||||||
|
raise RuntimeError('Automatic boot deployment currently supports plain ext4/Btrfs roots. Keep the existing loader for encrypted or other layouts.')
|
||||||
|
source = re.sub(r'\[.*\]$', '', root['source'])
|
||||||
|
device_type = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'TYPE', source], text=True, capture_output=True)
|
||||||
|
if device_type.returncode or device_type.stdout.strip() not in ('part', 'disk'):
|
||||||
|
raise RuntimeError('Automatic boot deployment needs a plain disk/partition root; keep the existing loader for this storage layout.')
|
||||||
|
target = discover_esp(esp)
|
||||||
|
images = kernel_images(exclude=exclude)
|
||||||
|
if not images:
|
||||||
|
raise RuntimeError('No installed kernel/initramfs candidates found under /boot. Install a distribution kernel first.')
|
||||||
|
text = kernel_cmdline(root)
|
||||||
|
result = {'mode': mode, 'esp': target, 'root_filesystem': root['fstype'],
|
||||||
|
'cmdline': text, 'images': images, 'secure_boot': secure_boot(),
|
||||||
|
'firmware': 'preserve current entries/order unless explicitly selected',
|
||||||
|
'kernel_refresh_hook': '/etc/kernel/install.d/96-fiw-dots-boot.install'}
|
||||||
|
if mode == 'limine':
|
||||||
|
result['loader'] = str(limine_loader())
|
||||||
|
result['config'] = str(Path(target['path']) / EFI_DIRECTORY / 'limine.conf')
|
||||||
|
result['proposed_entries'] = render_limine(images, text)
|
||||||
|
else:
|
||||||
|
result['config'] = str(GRUB_DIRECTORY / 'grub/grub.cfg')
|
||||||
|
result['commands'] = [['grub-install', '--target=x86_64-efi', '--efi-directory=' + target['path'],
|
||||||
|
'--boot-directory=' + str(GRUB_DIRECTORY), '--bootloader-id=Fiw-Gentoo', '--no-nvram'],
|
||||||
|
['grub-mkconfig', '-o', result['config']]]
|
||||||
|
prior = Path('/etc/kernel/install.conf').read_text() if Path('/etc/kernel/install.conf').is_file() else ''
|
||||||
|
result['installkernel_config'] = install_config(prior)
|
||||||
|
for image in images:
|
||||||
|
if not image['initrd']:
|
||||||
|
image['generate_initrd'] = '/boot/initramfs-' + image['version'] + '.img'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def backup(path, root):
|
||||||
|
if path.exists() or path.is_symlink():
|
||||||
|
target = root / str(path.absolute()).lstrip('/')
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, target, follow_symlinks=False)
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, content, backups, mode=0o644):
|
||||||
|
backup(path, backups)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_name('.' + path.name + '.fiw-dots-tmp')
|
||||||
|
tmp.write_text(content)
|
||||||
|
tmp.chmod(mode)
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh(saved, backups, exclude=None):
|
||||||
|
# Recheck the mount every time; never write into an unmounted ESP directory.
|
||||||
|
target = discover_esp(Path(saved['esp']))
|
||||||
|
images = kernel_images(exclude=exclude)
|
||||||
|
if not images:
|
||||||
|
raise RuntimeError('No remaining kernels; preserving the boot menu.')
|
||||||
|
for image in images:
|
||||||
|
if not image['initrd']:
|
||||||
|
if not shutil.which('dracut'):
|
||||||
|
raise RuntimeError('Missing initramfs for ' + image['version'] + '; install sys-kernel/dracut.')
|
||||||
|
path = Path('/boot/initramfs-' + image['version'] + '.img')
|
||||||
|
subprocess.run(['dracut', '--force', str(path), image['version']], check=True)
|
||||||
|
image['initrd'] = str(path)
|
||||||
|
if saved['mode'] == 'grub':
|
||||||
|
config = GRUB_DIRECTORY / 'grub/grub.cfg'
|
||||||
|
old_hash = file_hash(config)
|
||||||
|
output = config
|
||||||
|
if old_hash and old_hash != saved.get('config_hash'):
|
||||||
|
output = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
|
||||||
|
else:
|
||||||
|
backup(config, backups)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
for image in images:
|
||||||
|
source = Path(image['kernel'])
|
||||||
|
if source.parent != Path('/boot'):
|
||||||
|
dest = Path('/boot') / ('kernel-' + image['version'])
|
||||||
|
if file_hash(dest) != file_hash(source):
|
||||||
|
backup(dest, backups)
|
||||||
|
shutil.copy2(source, dest)
|
||||||
|
source = Path(image['initrd'])
|
||||||
|
dest = Path('/boot') / ('initramfs-' + image['version'] + '.img')
|
||||||
|
if source != dest and not dest.exists():
|
||||||
|
shutil.copy2(source, dest)
|
||||||
|
scratch = output.with_name('.' + output.name + '.tmp')
|
||||||
|
try:
|
||||||
|
subprocess.run(['grub-mkconfig', '-o', str(scratch)], check=True)
|
||||||
|
scratch.replace(output)
|
||||||
|
finally:
|
||||||
|
if scratch.exists():
|
||||||
|
scratch.unlink()
|
||||||
|
if output != config:
|
||||||
|
print('Preserved edited GRUB config; review ' + str(output))
|
||||||
|
else:
|
||||||
|
saved['config_hash'] = file_hash(config)
|
||||||
|
return saved
|
||||||
|
folder = Path(target['path']) / EFI_DIRECTORY
|
||||||
|
required = sum(Path(source).stat().st_size for image in images
|
||||||
|
for source, name in [(image['kernel'], 'vmlinuz'), (image['initrd'], 'initramfs.img')]
|
||||||
|
if file_hash(folder / 'kernels' / image['version'] / name) != file_hash(Path(source)))
|
||||||
|
if shutil.disk_usage(target['path']).free < required + 16 * 1024 * 1024:
|
||||||
|
raise RuntimeError('Not enough free space on the ESP for the selected kernels and initramfs files.')
|
||||||
|
for image in images:
|
||||||
|
directory = folder / 'kernels' / image['version']
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
for source, name in [(image['kernel'], 'vmlinuz'), (image['initrd'], 'initramfs.img')]:
|
||||||
|
dest = directory / name
|
||||||
|
if file_hash(dest) != file_hash(Path(source)):
|
||||||
|
backup(dest, backups)
|
||||||
|
scratch = dest.with_name('.' + name + '.tmp')
|
||||||
|
shutil.copy2(source, scratch)
|
||||||
|
scratch.replace(dest)
|
||||||
|
config = folder / 'limine.conf'
|
||||||
|
old = config.read_text() if config.is_file() else ''
|
||||||
|
prior = managed_block(old)
|
||||||
|
block = render_limine(images, saved['cmdline'])
|
||||||
|
output = merge_limine(old, block)
|
||||||
|
if prior and block_hash(prior) != saved.get('block_hash'):
|
||||||
|
proposal = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ'))
|
||||||
|
write(proposal, output, backups)
|
||||||
|
print('Preserved edited Limine entries; review ' + str(proposal))
|
||||||
|
elif output != old:
|
||||||
|
write(config, output, backups)
|
||||||
|
saved['block_hash'] = block_hash(block)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def confirm(prompt, choices, default):
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raise RuntimeError('Interactive confirmation required: ' + prompt)
|
||||||
|
while True:
|
||||||
|
value = input(prompt + ' [' + '/'.join(choices) + '] (default ' + default + '): ').strip().lower() or default
|
||||||
|
if value in choices:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def firmware_commands(target, mode, loader_path, choice, listing):
|
||||||
|
if choice == 'keep':
|
||||||
|
return []
|
||||||
|
label = 'Fiw-Gentoo-' + mode
|
||||||
|
matches = []
|
||||||
|
for line in listing.splitlines():
|
||||||
|
match = re.match(r'^Boot([0-9A-Fa-f]{4})\*?\s+' + re.escape(label) + r'\s', line)
|
||||||
|
if match and target.get('partuuid') and target['partuuid'] in line.lower() and loader_path.lower().replace('/', '\\') in line.lower():
|
||||||
|
matches.append(match.group(1))
|
||||||
|
if matches:
|
||||||
|
if choice == 'add':
|
||||||
|
return []
|
||||||
|
order = re.search(r'^BootOrder:\s*(.*)$', listing, re.M)
|
||||||
|
sequence = [matches[0]] + [item for item in (order.group(1).split(',') if order else [])
|
||||||
|
if item.upper() != matches[0].upper()]
|
||||||
|
return [['efibootmgr', '--bootorder', ','.join(sequence)]]
|
||||||
|
return [['efibootmgr', '--create' if choice == 'default' else '--create-only',
|
||||||
|
'--disk', target['disk'], '--part', str(target['partition']),
|
||||||
|
'--label', label, '--loader', loader_path.replace('/', '\\')]]
|
||||||
|
|
||||||
|
|
||||||
|
def deploy(mode, esp=None, ask=confirm):
|
||||||
|
if mode == 'keep':
|
||||||
|
print('Keeping the current bootloader.')
|
||||||
|
return
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
raise RuntimeError('Boot deployment needs root. Run sudo ./install --deploy-bootloader --selection <file>.')
|
||||||
|
proposed = plan(mode, esp)
|
||||||
|
if proposed['secure_boot']:
|
||||||
|
raise RuntimeError('Secure Boot is active. This unsigned deployment requires a separately configured signing setup.')
|
||||||
|
for command in (['grub-install', 'grub-mkconfig'] if mode == 'grub' else []):
|
||||||
|
if not shutil.which(command):
|
||||||
|
raise RuntimeError('Missing command: ' + command)
|
||||||
|
if any(not image['initrd'] for image in proposed['images']) and not shutil.which('dracut'):
|
||||||
|
raise RuntimeError('Missing initramfs generator: dracut')
|
||||||
|
print(json.dumps(proposed, indent=2))
|
||||||
|
firmware = ask('UEFI entry: preserve current order, add a menu entry, or make this loader default?',
|
||||||
|
['keep', 'add', 'default'], 'keep')
|
||||||
|
if firmware != 'keep' and not shutil.which('efibootmgr'):
|
||||||
|
raise RuntimeError('Install sys-boot/efibootmgr before registering a firmware entry.')
|
||||||
|
loader_path = '/EFI/Fiw-Gentoo/' + ('BOOTX64.EFI' if mode == 'limine' else 'grubx64.efi')
|
||||||
|
listing = subprocess.run(['efibootmgr', '--verbose'], text=True, capture_output=True, check=True).stdout if firmware != 'keep' else ''
|
||||||
|
commands = firmware_commands(proposed['esp'], mode, loader_path, firmware, listing)
|
||||||
|
if commands:
|
||||||
|
print('Proposed firmware commands: ' + json.dumps(commands))
|
||||||
|
if ask('Deploy the previewed bootloader and kernel refresh hook?', ['deploy', 'cancel'], 'cancel') != 'deploy':
|
||||||
|
return
|
||||||
|
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||||
|
backups = MANAGED / 'backups' / ('boot-' + stamp)
|
||||||
|
folder = Path(proposed['esp']['path']) / EFI_DIRECTORY
|
||||||
|
if folder.exists():
|
||||||
|
shutil.copytree(folder, backups / 'efi-directory', symlinks=True)
|
||||||
|
if GRUB_DIRECTORY.exists() and mode == 'grub':
|
||||||
|
shutil.copytree(GRUB_DIRECTORY, backups / 'grub-directory', symlinks=True)
|
||||||
|
prior = json.loads(STATE.read_text()) if STATE.is_file() else {}
|
||||||
|
saved = {'mode': mode, 'esp': proposed['esp']['path'], 'cmdline': proposed['cmdline']}
|
||||||
|
if prior.get('mode') == mode and prior.get('esp') == saved['esp']:
|
||||||
|
saved.update({key: prior[key] for key in ('config_hash', 'block_hash') if key in prior})
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
if mode == 'limine':
|
||||||
|
loader = folder / 'BOOTX64.EFI'
|
||||||
|
backup(loader, backups)
|
||||||
|
shutil.copy2(proposed['loader'], loader)
|
||||||
|
loader_path = '/EFI/Fiw-Gentoo/BOOTX64.EFI'
|
||||||
|
else:
|
||||||
|
subprocess.run(proposed['commands'][0], check=True)
|
||||||
|
loader_path = '/EFI/Fiw-Gentoo/grubx64.efi'
|
||||||
|
saved = refresh(saved, backups)
|
||||||
|
# Native installkernel provides versioned /boot kernel/initrd files; our
|
||||||
|
# final hook refreshes only the chosen loader's own configuration.
|
||||||
|
write(Path('/etc/kernel/install.conf'), proposed['installkernel_config'], backups)
|
||||||
|
script = Path('/usr/local/libexec/fiw-dots-boot')
|
||||||
|
backup(script, backups)
|
||||||
|
script.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(Path(__file__), script)
|
||||||
|
script.chmod(0o755)
|
||||||
|
hook = '#!/bin/sh\ncase "$1" in\n add) exec /usr/local/libexec/fiw-dots-boot --refresh ;;\n remove) exec /usr/local/libexec/fiw-dots-boot --refresh --exclude-version "$2" ;;\nesac\n'
|
||||||
|
write(Path('/etc/kernel/install.d/96-fiw-dots-boot.install'), hook, backups, 0o755)
|
||||||
|
# Traditional Gentoo installkernel uses postinst.d rather than install.d.
|
||||||
|
legacy = '#!/bin/sh\nexec /usr/local/libexec/fiw-dots-boot --refresh\n'
|
||||||
|
write(Path('/etc/kernel/postinst.d/96-fiw-dots-boot'), legacy, backups, 0o755)
|
||||||
|
write(STATE, json.dumps(saved, indent=2) + '\n', backups, 0o600)
|
||||||
|
for command in commands:
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
report = MANAGED / ('boot-' + stamp + '.json')
|
||||||
|
report.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware,
|
||||||
|
'backups': str(backups)}, indent=2) + '\n')
|
||||||
|
print('Boot deployment finished. Report: ' + str(report))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--refresh', action='store_true')
|
||||||
|
parser.add_argument('--exclude-version')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.refresh:
|
||||||
|
parser.error('Use ./install --boot-plan or --deploy-bootloader for initial setup.')
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
raise RuntimeError('Kernel refresh requires root.')
|
||||||
|
if not STATE.is_file():
|
||||||
|
return
|
||||||
|
saved = json.loads(STATE.read_text())
|
||||||
|
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||||
|
backups = MANAGED / 'backups' / ('boot-refresh-' + stamp)
|
||||||
|
updated = refresh(saved, backups, args.exclude_version)
|
||||||
|
write(STATE, json.dumps(updated, indent=2) + '\n', backups, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||||
|
print('Boot refresh: ' + str(error), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
80
lib/rice.py
80
lib/rice.py
|
|
@ -14,7 +14,11 @@ from pathlib import Path
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(REPO / 'tools'))
|
sys.path.insert(0, str(REPO / 'tools'))
|
||||||
|
sys.path.insert(0, str(REPO / 'lib'))
|
||||||
from capture import sections
|
from capture import sections
|
||||||
|
from setup import (FLATPAKS, SERVICES, required_repositories,
|
||||||
|
existing_repository, repository_config, stage_repositories,
|
||||||
|
publish_repositories, install_flatpaks, enable_services)
|
||||||
|
|
||||||
GROUPS = ['system', 'kde', 'fiw-apps', 'cli', 'dev', 'gaming', 'fiw-tools', 'tidewm']
|
GROUPS = ['system', 'kde', 'fiw-apps', 'cli', 'dev', 'gaming', 'fiw-tools', 'tidewm']
|
||||||
EXTRAS = {'filelight': 'Filelight', 'btrfs': 'Btrfs tools', 'nvidia': 'NVIDIA drivers'}
|
EXTRAS = {'filelight': 'Filelight', 'btrfs': 'Btrfs tools', 'nvidia': 'NVIDIA drivers'}
|
||||||
|
|
@ -29,8 +33,14 @@ def load_selection(path=None, profile='stock'):
|
||||||
selection = json.loads((Path(path) if path else REPO / 'presets' / (profile + '.json')).read_text())
|
selection = json.loads((Path(path) if path else REPO / 'presets' / (profile + '.json')).read_text())
|
||||||
if selection.get('profile') not in ('stock', 'fiw-ryzen'):
|
if selection.get('profile') not in ('stock', 'fiw-ryzen'):
|
||||||
raise ValueError('Unknown preset')
|
raise ValueError('Unknown preset')
|
||||||
for field, allowed in [('groups', GROUPS), ('configs', CATALOG), ('extras', EXTRAS)]:
|
for field in ('flatpaks', 'services'):
|
||||||
if any(x not in allowed for x in selection.get(field, [])):
|
if selection.get(field) is None:
|
||||||
|
selection[field] = []
|
||||||
|
for field, allowed in [('groups', GROUPS), ('configs', CATALOG), ('extras', EXTRAS),
|
||||||
|
('flatpaks', FLATPAKS), ('services', SERVICES)]:
|
||||||
|
if not isinstance(selection.get(field, []), list):
|
||||||
|
raise ValueError('Selection must be a list: ' + field)
|
||||||
|
if any(not isinstance(x, str) or x not in allowed for x in selection.get(field, [])):
|
||||||
raise ValueError('Unknown selection in ' + field)
|
raise ValueError('Unknown selection in ' + field)
|
||||||
if selection.get('kernel') not in ('binary', 'custom'):
|
if selection.get('kernel') not in ('binary', 'custom'):
|
||||||
raise ValueError('Unknown kernel choice')
|
raise ValueError('Unknown kernel choice')
|
||||||
|
|
@ -54,6 +64,17 @@ def package_map(selection):
|
||||||
result[extra] = atoms(REPO / 'packages/optional' / (extra + '.list'))
|
result[extra] = atoms(REPO / 'packages/optional' / (extra + '.list'))
|
||||||
if selection['bootloader'] != 'keep':
|
if selection['bootloader'] != 'keep':
|
||||||
result['boot-' + selection['bootloader']] = atoms(REPO / 'packages/optional' / (selection['bootloader'] + '.list'))
|
result['boot-' + selection['bootloader']] = atoms(REPO / 'packages/optional' / (selection['bootloader'] + '.list'))
|
||||||
|
support = []
|
||||||
|
if selection.get('flatpaks'):
|
||||||
|
support.append('sys-apps/flatpak')
|
||||||
|
for key in selection.get('services', []):
|
||||||
|
support += SERVICES[key]['packages']
|
||||||
|
if selection['bootloader'] != 'keep':
|
||||||
|
support += ['sys-kernel/installkernel', 'sys-kernel/dracut', 'sys-boot/efibootmgr']
|
||||||
|
selected = {atom.split('::')[0] for packages in result.values() for atom in packages}
|
||||||
|
support = [atom for atom in dict.fromkeys(support) if atom not in selected]
|
||||||
|
if support:
|
||||||
|
result['setup'] = support
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -153,11 +174,16 @@ def active_plasma(home):
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def check_configs(selection, home):
|
||||||
|
entries = list(config_entries(selection))
|
||||||
|
if any(CATALOG[name].get('kde') for name, _, _, _ in entries) and active_plasma(home.resolve()):
|
||||||
|
raise RuntimeError('Log out of Plasma and apply from a TTY to prevent KDE overwriting restored settings. Portable app configs can be applied separately.')
|
||||||
|
|
||||||
|
|
||||||
def apply_configs(selection, home, update=False, conflict='ask'):
|
def apply_configs(selection, home, update=False, conflict='ask'):
|
||||||
home = home.resolve()
|
home = home.resolve()
|
||||||
entries = list(config_entries(selection))
|
entries = list(config_entries(selection))
|
||||||
if any(CATALOG[name].get('kde') for name, _, _, _ in entries) and active_plasma(home):
|
check_configs(selection, home)
|
||||||
raise RuntimeError('Log out of Plasma and apply from a TTY to prevent KDE overwriting restored settings. Portable app configs can be applied separately.')
|
|
||||||
state_dir = home / '.local/state/Fiw-Gentoo-Dots'
|
state_dir = home / '.local/state/Fiw-Gentoo-Dots'
|
||||||
index_path = state_dir / 'managed.json'
|
index_path = state_dir / 'managed.json'
|
||||||
index = json.loads(index_path.read_text()) if index_path.is_file() else {}
|
index = json.loads(index_path.read_text()) if index_path.is_file() else {}
|
||||||
|
|
@ -255,8 +281,9 @@ def root_files(selection, repo_location=None):
|
||||||
result['etc/portage/fiw-dots.conf'] += '\n# Persist the selected binary/source policy for later emerge operations.\n'
|
result['etc/portage/fiw-dots.conf'] += '\n# Persist the selected binary/source policy for later emerge operations.\n'
|
||||||
result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n'
|
result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n'
|
||||||
result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n'
|
result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n'
|
||||||
for name, uri in [('guru', 'https://github.com/gentoo-mirror/guru.git'), ('steam-overlay', 'https://github.com/anyc/steam-overlay.git')]:
|
for name in required_repositories(selection):
|
||||||
result['etc/portage/repos.conf/fiw-dots-' + name + '.conf'] = '[' + name + ']\nlocation = /var/db/repos/' + name + '\nsync-type = git\nsync-uri = ' + uri + '\nsync-depth = 1\npriority = 10\n'
|
if not existing_repository(name):
|
||||||
|
result['etc/portage/repos.conf/fiw-dots-' + name + '.conf'] = repository_config(name, '/var/db/repos/' + name)
|
||||||
arch = 'x86-64-v3' if selection['profile'] == 'fiw-ryzen' else 'x86-64'
|
arch = 'x86-64-v3' if selection['profile'] == 'fiw-ryzen' else 'x86-64'
|
||||||
result['etc/portage/binrepos.conf/fiw-dots.conf'] = '[gentoo]\npriority = 1\nsync-uri = https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/' + arch + '/\nlocation = /var/cache/binhost/gentoo\nverify-signature = true\n'
|
result['etc/portage/binrepos.conf/fiw-dots.conf'] = '[gentoo]\npriority = 1\nsync-uri = https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/' + arch + '/\nlocation = /var/cache/binhost/gentoo\nverify-signature = true\n'
|
||||||
if selection['kernel'] == 'custom' and 'system' in selection['groups']:
|
if selection['kernel'] == 'custom' and 'system' in selection['groups']:
|
||||||
|
|
@ -265,6 +292,14 @@ def root_files(selection, repo_location=None):
|
||||||
result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n'
|
result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n'
|
||||||
result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text()
|
result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text()
|
||||||
result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel snapshot; update snapshot and patch explicitly.\n>sys-kernel/gentoo-kernel-7.2.8\n'
|
result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel snapshot; update snapshot and patch explicitly.\n>sys-kernel/gentoo-kernel-7.2.8\n'
|
||||||
|
if selection['bootloader'] != 'keep':
|
||||||
|
result['etc/portage/package.use/fiw-dots-boot'] = ('sys-kernel/installkernel systemd dracut -grub -uki -ukify -ugrd -efistub -refind -systemd-boot\n')
|
||||||
|
if selection['bootloader'] == 'grub':
|
||||||
|
result['etc/portage/env/fiw-dots-grub.conf'] = 'GRUB_PLATFORMS="efi-64"\n'
|
||||||
|
result['etc/portage/package.env/fiw-dots-grub'] = 'sys-boot/grub fiw-dots-grub.conf\n'
|
||||||
|
else:
|
||||||
|
result['etc/portage/package.accept_keywords/fiw-dots-limine'] = 'sys-boot/limine ~amd64\n'
|
||||||
|
result['etc/portage/package.use/fiw-dots-limine'] = 'sys-boot/limine uefi-x86-64 -bios -bios-cd -bios-pxe -uefi-cd -uefi-ia32 -uefi-aarch64 -uefi-riscv64 -uefi-loongarch64\n'
|
||||||
if 'tidewm' in selection['groups']:
|
if 'tidewm' in selection['groups']:
|
||||||
result['etc/portage/package.accept_keywords/fiw-dots-tidewm'] = 'gui-wm/tidewm::fiw-dots **\n'
|
result['etc/portage/package.accept_keywords/fiw-dots-tidewm'] = 'gui-wm/tidewm::fiw-dots **\n'
|
||||||
return result
|
return result
|
||||||
|
|
@ -275,7 +310,11 @@ def preview(selection):
|
||||||
lines = ["Fiw-Gentoo-Dots — " + selection['name'], '']
|
lines = ["Fiw-Gentoo-Dots — " + selection['name'], '']
|
||||||
for group, values in packages.items():
|
for group, values in packages.items():
|
||||||
lines += [group + ' (' + str(len(values)) + ')', ' ' + ', '.join(values)]
|
lines += [group + ' (' + str(len(values)) + ')', ' ' + ', '.join(values)]
|
||||||
lines += ['', 'Compile deliberately: ' + ', '.join(source_policy(selection)),
|
lines += ['', 'Repositories: ' + (', '.join(required_repositories(selection)) or 'Gentoo + fiw-dots only'),
|
||||||
|
'Missing selected overlays are staged before resolution and adopted after confirmation.',
|
||||||
|
'Flatpaks (user): ' + (', '.join(selection.get('flatpaks', [])) or 'none'),
|
||||||
|
'Optional services: ' + (', '.join(selection.get('services', [])) or 'none'),
|
||||||
|
'Compile deliberately: ' + ', '.join(source_policy(selection)),
|
||||||
'Other packages: prefer binaries; ask compile/skip for additional source builds.',
|
'Other packages: prefer binaries; ask compile/skip for additional source builds.',
|
||||||
'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''),
|
'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''),
|
||||||
'Bootloader: ' + selection['bootloader'], '', 'Selected configs:']
|
'Bootloader: ' + selection['bootloader'], '', 'Selected configs:']
|
||||||
|
|
@ -286,7 +325,7 @@ def preview(selection):
|
||||||
'Existing config conflicts: ask; updates preserve edits as .new.']
|
'Existing config conflicts: ask; updates preserve edits as .new.']
|
||||||
|
|
||||||
if selection['bootloader'] != 'keep':
|
if selection['bootloader'] != 'keep':
|
||||||
lines.append('Bootloader package selected; ESP/firmware deployment is a separate explicit setup step (docs/boot.md).')
|
lines.append('Boot deployment: target ESP and boot files are previewed before applying; firmware changes are separately selectable (docs/boot.md).')
|
||||||
if selection.get('firefox_privacy'):
|
if selection.get('firefox_privacy'):
|
||||||
lines.append('Optional manual setup: Firefox-Privacy (docs/firefox.md).')
|
lines.append('Optional manual setup: Firefox-Privacy (docs/firefox.md).')
|
||||||
if selection.get('spotify_custom'):
|
if selection.get('spotify_custom'):
|
||||||
|
|
@ -354,6 +393,7 @@ def install_packages(selection):
|
||||||
profile.unlink()
|
profile.unlink()
|
||||||
profile.symlink_to(Path('/etc/portage/make.profile').resolve())
|
profile.symlink_to(Path('/etc/portage/make.profile').resolve())
|
||||||
export(selection, stage)
|
export(selection, stage)
|
||||||
|
fetched = stage_repositories(selection, stage)
|
||||||
make_conf = stage / 'etc/portage/make.conf'
|
make_conf = stage / 'etc/portage/make.conf'
|
||||||
make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n')
|
make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n')
|
||||||
# Repository checkout is local and read-only for resolution. No live
|
# Repository checkout is local and read-only for resolution. No live
|
||||||
|
|
@ -392,6 +432,7 @@ def install_packages(selection):
|
||||||
return
|
return
|
||||||
if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply':
|
if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply':
|
||||||
return
|
return
|
||||||
|
publish_repositories(fetched)
|
||||||
# Back up each existing managed destination before replacing it.
|
# Back up each existing managed destination before replacing it.
|
||||||
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||||
backup_root = Path('/var/lib/Fiw-Gentoo-Dots/backups') / stamp
|
backup_root = Path('/var/lib/Fiw-Gentoo-Dots/backups') / stamp
|
||||||
|
|
@ -456,7 +497,7 @@ def install_packages(selection):
|
||||||
raise RuntimeError('Some packages did not install; consult the package report and emerge output.')
|
raise RuntimeError('Some packages did not install; consult the package report and emerge output.')
|
||||||
if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing:
|
if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing:
|
||||||
subprocess.run(['systemctl', 'enable', '--force', 'plasmalogin.service'], check=True)
|
subprocess.run(['systemctl', 'enable', '--force', 'plasmalogin.service'], check=True)
|
||||||
print('Portage installation finished. Plasma Login Manager is enabled for selected KDE installs; no running greeter was restarted. Flatpaks and boot deployment are explicit follow-up steps; see docs/setup.md.')
|
print('Portage installation finished. Plasma Login Manager is enabled for selected KDE installs; no running greeter was restarted. Use the selected Flatpak, service and boot actions or the full restore workflow to finish setup.')
|
||||||
|
|
||||||
|
|
||||||
def check_packages(selection):
|
def check_packages(selection):
|
||||||
|
|
@ -470,6 +511,7 @@ def check_packages(selection):
|
||||||
profile.unlink()
|
profile.unlink()
|
||||||
profile.symlink_to(Path('/etc/portage/make.profile').resolve())
|
profile.symlink_to(Path('/etc/portage/make.profile').resolve())
|
||||||
export(selection, stage)
|
export(selection, stage)
|
||||||
|
fetched = stage_repositories(selection, stage)
|
||||||
make_conf = stage / 'etc/portage/make.conf'
|
make_conf = stage / 'etc/portage/make.conf'
|
||||||
make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n')
|
make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n')
|
||||||
result = resolve(stage, selection, requested)
|
result = resolve(stage, selection, requested)
|
||||||
|
|
@ -489,18 +531,28 @@ def main():
|
||||||
parser.add_argument('--catalog', action='store_true')
|
parser.add_argument('--catalog', action='store_true')
|
||||||
parser.add_argument('--export', type=Path)
|
parser.add_argument('--export', type=Path)
|
||||||
parser.add_argument('--apply-configs', action='store_true')
|
parser.add_argument('--apply-configs', action='store_true')
|
||||||
|
parser.add_argument('--check-configs', action='store_true')
|
||||||
parser.add_argument('--install-packages', action='store_true')
|
parser.add_argument('--install-packages', action='store_true')
|
||||||
parser.add_argument('--check-packages', action='store_true')
|
parser.add_argument('--check-packages', action='store_true')
|
||||||
|
parser.add_argument('--install-flatpaks', action='store_true')
|
||||||
|
parser.add_argument('--enable-services', action='store_true')
|
||||||
|
parser.add_argument('--deploy-bootloader', action='store_true')
|
||||||
|
parser.add_argument('--boot-plan', action='store_true')
|
||||||
|
parser.add_argument('--esp', type=Path)
|
||||||
parser.add_argument('--update', action='store_true')
|
parser.add_argument('--update', action='store_true')
|
||||||
parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask')
|
parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.catalog:
|
if args.catalog:
|
||||||
print(json.dumps({'groups': GROUPS, 'configs': CATALOG, 'extras': EXTRAS,
|
print(json.dumps({'groups': GROUPS, 'configs': CATALOG, 'extras': EXTRAS,
|
||||||
|
'flatpaks': FLATPAKS, 'services': SERVICES,
|
||||||
'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')}))
|
'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')}))
|
||||||
return
|
return
|
||||||
selection = load_selection(args.selection, args.profile)
|
selection = load_selection(args.selection, args.profile)
|
||||||
if args.export:
|
if args.export:
|
||||||
export(selection, args.export)
|
export(selection, args.export)
|
||||||
|
elif args.check_configs:
|
||||||
|
check_configs(selection, args.home)
|
||||||
|
print('Config restoration preflight passed.')
|
||||||
elif args.apply_configs:
|
elif args.apply_configs:
|
||||||
if os.geteuid() == 0:
|
if os.geteuid() == 0:
|
||||||
raise RuntimeError('Apply user configs as the target user, not as root.')
|
raise RuntimeError('Apply user configs as the target user, not as root.')
|
||||||
|
|
@ -509,6 +561,16 @@ def main():
|
||||||
install_packages(selection)
|
install_packages(selection)
|
||||||
elif args.check_packages:
|
elif args.check_packages:
|
||||||
check_packages(selection)
|
check_packages(selection)
|
||||||
|
elif args.install_flatpaks:
|
||||||
|
install_flatpaks(selection, ask)
|
||||||
|
elif args.enable_services:
|
||||||
|
enable_services(selection, ask)
|
||||||
|
elif args.deploy_bootloader or args.boot_plan:
|
||||||
|
from boot import deploy, plan
|
||||||
|
if args.boot_plan:
|
||||||
|
print(json.dumps(plan(selection['bootloader'], args.esp), indent=2))
|
||||||
|
else:
|
||||||
|
deploy(selection['bootloader'], args.esp, ask)
|
||||||
else:
|
else:
|
||||||
print(preview(selection))
|
print(preview(selection))
|
||||||
|
|
||||||
|
|
|
||||||
174
lib/setup.py
Normal file
174
lib/setup.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""Optional repositories, Flatpaks and services for the restoration workflow."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPOSITORIES = {
|
||||||
|
'guru': 'https://github.com/gentoo-mirror/guru.git',
|
||||||
|
'steam-overlay': 'https://github.com/anyc/steam-overlay.git',
|
||||||
|
}
|
||||||
|
FLATPAKS = {}
|
||||||
|
LABELS = {'org.localsend.localsend_app': 'LocalSend', 'org.vinegarhq.Sober': 'Sober'}
|
||||||
|
for list_file in sorted((Path(__file__).resolve().parents[1] / 'flatpaks').glob('*.list')):
|
||||||
|
for line in list_file.read_text().splitlines():
|
||||||
|
app = line.strip()
|
||||||
|
if not app or app.startswith('#'):
|
||||||
|
continue
|
||||||
|
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_.-]*', app) or app.count('.') < 2:
|
||||||
|
raise ValueError('Invalid Flatpak ID in ' + list_file.name)
|
||||||
|
FLATPAKS[app] = {'label': LABELS.get(app, app), 'group': list_file.stem}
|
||||||
|
SERVICES = {
|
||||||
|
'audio': {'label': 'PipeWire audio (user)', 'scope': 'user',
|
||||||
|
'units': ['pipewire.socket', 'pipewire-pulse.socket', 'wireplumber.service'],
|
||||||
|
'packages': ['media-video/pipewire', 'media-video/wireplumber']},
|
||||||
|
'network': {'label': 'NetworkManager (system)', 'scope': 'system',
|
||||||
|
'units': ['NetworkManager.service'], 'packages': ['net-misc/networkmanager']},
|
||||||
|
'bluetooth': {'label': 'Bluetooth (system)', 'scope': 'system',
|
||||||
|
'units': ['bluetooth.service'], 'packages': ['net-wireless/bluez']},
|
||||||
|
'power': {'label': 'Power profiles (system)', 'scope': 'system',
|
||||||
|
'units': ['power-profiles-daemon.service'], 'packages': ['sys-power/power-profiles-daemon']},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def required_repositories(selection):
|
||||||
|
names = []
|
||||||
|
if any(g in selection['groups'] for g in ('fiw-apps', 'gaming')):
|
||||||
|
names.append('guru')
|
||||||
|
if 'gaming' in selection['groups']:
|
||||||
|
names.append('steam-overlay')
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def existing_repository(name):
|
||||||
|
if not shutil.which('portageq'):
|
||||||
|
return None
|
||||||
|
result = subprocess.run(['portageq', 'get_repo_path', '/', name], text=True,
|
||||||
|
capture_output=True)
|
||||||
|
if result.returncode or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
path = Path(result.stdout.strip())
|
||||||
|
if (path / 'profiles/repo_name').is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def repository_config(name, location):
|
||||||
|
return (f'[{name}]\nlocation = {location}\nsync-type = git\n'
|
||||||
|
f'sync-uri = {REPOSITORIES[name]}\nsync-depth = 1\npriority = 10\n')
|
||||||
|
|
||||||
|
|
||||||
|
def stage_repositories(selection, stage):
|
||||||
|
"""Fetch missing selected repositories into the temporary resolution root."""
|
||||||
|
fetched = {}
|
||||||
|
for name in required_repositories(selection):
|
||||||
|
if existing_repository(name):
|
||||||
|
continue
|
||||||
|
if not shutil.which('git'):
|
||||||
|
raise RuntimeError('Repository preparation needs Git. Install dev-vcs/git first.')
|
||||||
|
path = stage / 'var/db/repos' / name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
print('Preparing missing repository in temporary storage: ' + name, flush=True)
|
||||||
|
subprocess.run(['git', 'clone', '--depth=1', REPOSITORIES[name], str(path)], check=True)
|
||||||
|
if (path / 'profiles/repo_name').read_text().strip() != name:
|
||||||
|
raise RuntimeError('Unexpected repository identity for ' + name)
|
||||||
|
config = stage / 'etc/portage/repos.conf' / ('fiw-dots-' + name + '.conf')
|
||||||
|
config.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
config.write_text(repository_config(name, path))
|
||||||
|
fetched[name] = path
|
||||||
|
return fetched
|
||||||
|
|
||||||
|
|
||||||
|
def publish_repositories(fetched):
|
||||||
|
"""Adopt verified checkouts only after the installation confirmation."""
|
||||||
|
for name, source in fetched.items():
|
||||||
|
target = Path('/var/db/repos') / name
|
||||||
|
if target.exists():
|
||||||
|
raise RuntimeError('Repository destination already exists; preserving it: ' + str(target))
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
scratch = target.with_name('.fiw-dots-' + name + '-' + str(os.getpid()))
|
||||||
|
try:
|
||||||
|
shutil.copytree(source, scratch, symlinks=True)
|
||||||
|
scratch.rename(target)
|
||||||
|
finally:
|
||||||
|
if scratch.exists():
|
||||||
|
shutil.rmtree(scratch)
|
||||||
|
|
||||||
|
|
||||||
|
def write_report(kind, report, system=False):
|
||||||
|
state = Path('/var/lib/Fiw-Gentoo-Dots') if system else Path.home() / '.local/state/Fiw-Gentoo-Dots'
|
||||||
|
state.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||||
|
path = state / (kind + '-' + stamp + '.json')
|
||||||
|
path.write_text(json.dumps(report, indent=2) + '\n')
|
||||||
|
print('Report: ' + str(path))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def install_flatpaks(selection, ask):
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
raise RuntimeError('Install user Flatpaks as the target user, not root.')
|
||||||
|
apps = selection.get('flatpaks', [])
|
||||||
|
if not apps:
|
||||||
|
print('No Flatpaks selected.')
|
||||||
|
return
|
||||||
|
if not shutil.which('flatpak'):
|
||||||
|
raise RuntimeError('Flatpak is missing. Install sys-apps/flatpak or select it through the package workflow.')
|
||||||
|
missing = [app for app in apps if subprocess.run(
|
||||||
|
['flatpak', 'info', '--user', app], stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL).returncode]
|
||||||
|
report = {'selected': apps, 'already_installed': [app for app in apps if app not in missing],
|
||||||
|
'installed': [], 'failed': [], 'skipped': []}
|
||||||
|
print('User Flatpaks from Flathub: ' + (', '.join(missing) or 'all selected apps already installed'))
|
||||||
|
if missing and ask('Install these selected Flatpaks for your user?', ['install', 'skip'], 'install') == 'skip':
|
||||||
|
report['skipped'] = missing
|
||||||
|
elif missing:
|
||||||
|
remote = subprocess.run(['flatpak', 'remote-add', '--user', '--if-not-exists',
|
||||||
|
'flathub', 'https://flathub.org/repo/flathub.flatpakrepo'])
|
||||||
|
if remote.returncode:
|
||||||
|
report['failed'] = missing
|
||||||
|
else:
|
||||||
|
for app in missing:
|
||||||
|
result = subprocess.run(['flatpak', 'install', '--user', '--noninteractive',
|
||||||
|
'--assumeyes', 'flathub', app])
|
||||||
|
present = subprocess.run(['flatpak', 'info', '--user', app],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
report['installed' if not result.returncode and not present.returncode else 'failed'].append(app)
|
||||||
|
write_report('flatpaks', report)
|
||||||
|
print('Missing Flatpaks: ' + (', '.join(report['failed'] + report['skipped']) or 'none'))
|
||||||
|
if report['failed']:
|
||||||
|
raise RuntimeError('Some Flatpaks did not install; see the report.')
|
||||||
|
|
||||||
|
|
||||||
|
def enable_services(selection, ask):
|
||||||
|
system = os.geteuid() == 0
|
||||||
|
scope = 'system' if system else 'user'
|
||||||
|
units = list(dict.fromkeys(unit for key in selection.get('services', [])
|
||||||
|
if SERVICES[key]['scope'] == scope for unit in SERVICES[key]['units']))
|
||||||
|
if not units:
|
||||||
|
print('No ' + scope + ' services selected.')
|
||||||
|
return
|
||||||
|
command = ['systemctl'] + ([] if system else ['--user'])
|
||||||
|
report = {'scope': scope, 'selected': units, 'enabled': [], 'missing': [], 'failed': [], 'skipped': []}
|
||||||
|
available = []
|
||||||
|
for unit in units:
|
||||||
|
result = subprocess.run(command + ['show', '--property=LoadState', '--value', unit],
|
||||||
|
text=True, capture_output=True)
|
||||||
|
if result.returncode or result.stdout.strip() != 'loaded':
|
||||||
|
report['missing'].append(unit)
|
||||||
|
else:
|
||||||
|
available.append(unit)
|
||||||
|
print('Enable at next boot/login: ' + (', '.join(available) or 'none'))
|
||||||
|
if available and ask('Enable the selected ' + scope + ' services?', ['enable', 'skip'], 'enable') == 'skip':
|
||||||
|
report['skipped'] = available
|
||||||
|
else:
|
||||||
|
for unit in available:
|
||||||
|
result = subprocess.run(command + ['enable', unit])
|
||||||
|
report['enabled' if not result.returncode else 'failed'].append(unit)
|
||||||
|
write_report('services', report, system)
|
||||||
|
print('Missing service units: ' + (', '.join(report['missing']) or 'none'))
|
||||||
|
if report['failed']:
|
||||||
|
raise RuntimeError('Some services could not be enabled; see the report.')
|
||||||
|
|
@ -31,5 +31,7 @@
|
||||||
"btrfs"
|
"btrfs"
|
||||||
],
|
],
|
||||||
"firefox_privacy": false,
|
"firefox_privacy": false,
|
||||||
"spotify_custom": false
|
"spotify_custom": false,
|
||||||
|
"flatpaks": [],
|
||||||
|
"services": []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,7 @@
|
||||||
"bootloader": "keep",
|
"bootloader": "keep",
|
||||||
"extras": [],
|
"extras": [],
|
||||||
"firefox_privacy": false,
|
"firefox_privacy": false,
|
||||||
"spotify_custom": false
|
"spotify_custom": false,
|
||||||
|
"flatpaks": [],
|
||||||
|
"services": []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
tests/test_setup.py
Normal file
142
tests/test_setup.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
LIB = Path(__file__).resolve().parents[1] / 'lib'
|
||||||
|
|
||||||
|
|
||||||
|
def module(name):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, LIB / (name + '.py'))
|
||||||
|
value = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
setup = module('setup')
|
||||||
|
boot = module('boot')
|
||||||
|
|
||||||
|
|
||||||
|
class SetupTests(unittest.TestCase):
|
||||||
|
def test_only_selected_overlays_are_staged_and_existing_ones_are_preserved(self):
|
||||||
|
self.assertEqual(setup.required_repositories({'groups': ['kde']}), [])
|
||||||
|
self.assertEqual(setup.required_repositories({'groups': ['gaming']}), ['guru', 'steam-overlay'])
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
def clone(command, **kwargs):
|
||||||
|
destination = Path(command[-1])
|
||||||
|
(destination / 'profiles').mkdir(parents=True)
|
||||||
|
(destination / 'profiles/repo_name').write_text(destination.name + '\n')
|
||||||
|
return subprocess.CompletedProcess(command, 0)
|
||||||
|
with patch.object(setup, 'existing_repository', side_effect=lambda name: Path('/existing') if name == 'guru' else None), patch.object(setup.shutil, 'which', return_value='/usr/bin/git'), patch.object(setup.subprocess, 'run', side_effect=clone) as run:
|
||||||
|
fetched = setup.stage_repositories({'groups': ['gaming']}, root)
|
||||||
|
self.assertEqual(set(fetched), {'steam-overlay'})
|
||||||
|
self.assertEqual(run.call_count, 1)
|
||||||
|
config = root / 'etc/portage/repos.conf/fiw-dots-steam-overlay.conf'
|
||||||
|
self.assertIn(str(root / 'var/db/repos/steam-overlay'), config.read_text())
|
||||||
|
self.assertFalse((root / 'etc/portage/repos.conf/fiw-dots-guru.conf').exists())
|
||||||
|
|
||||||
|
def test_flatpak_failure_is_reported_and_installs_are_user_scoped(self):
|
||||||
|
reports = []
|
||||||
|
installed = set()
|
||||||
|
def run(command, **kwargs):
|
||||||
|
if command[:2] == ['flatpak', 'info']:
|
||||||
|
return subprocess.CompletedProcess(command, 0 if command[-1] in installed else 1)
|
||||||
|
if 'install' in command and command[-1] == 'org.localsend.localsend_app':
|
||||||
|
installed.add(command[-1])
|
||||||
|
return subprocess.CompletedProcess(command, 0)
|
||||||
|
if 'install' in command:
|
||||||
|
return subprocess.CompletedProcess(command, 1)
|
||||||
|
return subprocess.CompletedProcess(command, 0)
|
||||||
|
with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.shutil, 'which', return_value='/usr/bin/flatpak'), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report: reports.append(report)):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
setup.install_flatpaks({'flatpaks': list(setup.FLATPAKS)}, lambda *args: 'install')
|
||||||
|
self.assertEqual(reports[0]['installed'], ['org.localsend.localsend_app'])
|
||||||
|
self.assertEqual(reports[0]['failed'], ['org.vinegarhq.Sober'])
|
||||||
|
for call in commands.call_args_list:
|
||||||
|
self.assertIn('--user', call.args[0])
|
||||||
|
|
||||||
|
def test_missing_services_are_reported_and_enabled_without_starting(self):
|
||||||
|
reports = []
|
||||||
|
def run(command, **kwargs):
|
||||||
|
if 'show' in command:
|
||||||
|
state = 'not-found\n' if command[-1] == 'wireplumber.service' else 'loaded\n'
|
||||||
|
return subprocess.CompletedProcess(command, 0, state)
|
||||||
|
return subprocess.CompletedProcess(command, 0)
|
||||||
|
with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report, system: reports.append(report)):
|
||||||
|
setup.enable_services({'services': ['audio', 'network']}, lambda *args: 'enable')
|
||||||
|
self.assertEqual(reports[0]['missing'], ['wireplumber.service'])
|
||||||
|
self.assertEqual(reports[0]['enabled'], ['pipewire.socket', 'pipewire-pulse.socket'])
|
||||||
|
for call in commands.call_args_list:
|
||||||
|
self.assertNotIn('--now', call.args[0])
|
||||||
|
self.assertIn('--user', call.args[0])
|
||||||
|
|
||||||
|
|
||||||
|
class BootTests(unittest.TestCase):
|
||||||
|
def test_managed_limine_block_preserves_other_entries_and_rejects_bad_markers(self):
|
||||||
|
original = 'timeout: 8\n\n/Other OS\n protocol: efi\n path: boot():/other.efi\n'
|
||||||
|
block = boot.render_limine([{'version': 'test-gentoo'}], 'root=UUID=target-root ro')
|
||||||
|
merged = boot.merge_limine(original, block)
|
||||||
|
self.assertTrue(merged.startswith(original))
|
||||||
|
self.assertEqual(boot.merge_limine(merged, block), merged)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
boot.merge_limine(original + boot.BEGIN, block)
|
||||||
|
|
||||||
|
def test_edited_limine_entries_get_proposal_while_external_entries_remain(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
image = root / 'kernel';image.write_bytes(b'kernel')
|
||||||
|
initrd = root / 'initrd';initrd.write_bytes(b'initrd')
|
||||||
|
images = [{'version': 'test-gentoo', 'kernel': str(image), 'initrd': str(initrd)}]
|
||||||
|
saved = {'mode': 'limine', 'esp': str(root / 'esp'), 'cmdline': 'root=UUID=target-root ro'}
|
||||||
|
(root / 'esp').mkdir()
|
||||||
|
with patch.object(boot, 'discover_esp', return_value={'path': saved['esp']}), patch.object(boot, 'kernel_images', return_value=images):
|
||||||
|
saved = boot.refresh(saved, root / 'backups')
|
||||||
|
config = root / 'esp/EFI/Fiw-Gentoo/limine.conf'
|
||||||
|
config.write_text(config.read_text() + '\n/Other OS\n protocol: efi\n')
|
||||||
|
saved = boot.refresh(saved, root / 'backups')
|
||||||
|
self.assertIn('/Other OS', config.read_text())
|
||||||
|
config.write_text(config.read_text().replace('target-root', 'user-edited-root'))
|
||||||
|
previous = config.read_text()
|
||||||
|
boot.refresh(saved, root / 'backups')
|
||||||
|
self.assertEqual(config.read_text(), previous)
|
||||||
|
proposals = list(config.parent.glob('limine.conf.new.*'))
|
||||||
|
self.assertEqual(len(proposals), 1)
|
||||||
|
self.assertIn('/Other OS', proposals[0].read_text())
|
||||||
|
|
||||||
|
def test_failed_grub_generation_does_not_replace_existing_menu(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
folder = root / 'boot';(folder / 'grub').mkdir(parents=True)
|
||||||
|
config = folder / 'grub/grub.cfg';config.write_text('working config\n')
|
||||||
|
images = []
|
||||||
|
def run(command, **kwargs):
|
||||||
|
Path(command[-1]).write_text('partial failed config')
|
||||||
|
raise subprocess.CalledProcessError(1, command)
|
||||||
|
# /boot sources prevent normalization writes outside the staging dir.
|
||||||
|
images = [{'version': 'test', 'kernel': '/boot/kernel-test', 'initrd': '/boot/initramfs-test.img'}]
|
||||||
|
saved = {'mode': 'grub', 'esp': str(root), 'config_hash': boot.file_hash(config)}
|
||||||
|
with patch.object(boot, 'GRUB_DIRECTORY', folder), patch.object(boot, 'discover_esp', return_value={'path': str(root)}), patch.object(boot, 'kernel_images', return_value=images), patch.object(boot.subprocess, 'run', side_effect=run):
|
||||||
|
with self.assertRaises(subprocess.CalledProcessError):
|
||||||
|
boot.refresh(saved, root / 'backups')
|
||||||
|
self.assertEqual(config.read_text(), 'working config\n')
|
||||||
|
self.assertFalse(list(config.parent.glob('.*.tmp')))
|
||||||
|
|
||||||
|
def test_firmware_add_preserves_bootorder_and_repeat_default_does_not_duplicate(self):
|
||||||
|
target = {'disk': '/dev/testdisk', 'partition': 2, 'partuuid': 'target-partition'}
|
||||||
|
loader = '/EFI/Fiw-Gentoo/BOOTX64.EFI'
|
||||||
|
command = boot.firmware_commands(target, 'limine', loader, 'add', '')[0]
|
||||||
|
self.assertIn('--create-only', command)
|
||||||
|
self.assertNotIn('--create', command)
|
||||||
|
listing = 'BootOrder: 0001,0002\nBoot0002* Fiw-Gentoo-limine HD(2,GPT,target-partition)/File(\\EFI\\Fiw-Gentoo\\BOOTX64.EFI)\n'
|
||||||
|
commands = boot.firmware_commands(target, 'limine', loader, 'default', listing)
|
||||||
|
self.assertEqual(commands, [['efibootmgr', '--bootorder', '0002,0001']])
|
||||||
|
self.assertEqual(boot.firmware_commands(target, 'limine', loader, 'add', listing), [])
|
||||||
|
self.assertEqual(boot.plan('keep'), {'mode': 'keep', 'changes': []})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Reference in a new issue