Add config update review and publish v0.1.1 bundle

This commit is contained in:
fi3w0 2026-10-03 22:51:16 +02:00
parent a8fd7c253d
commit 17b7d978bd
16 changed files with 535 additions and 26 deletions

View file

@ -37,7 +37,7 @@ The existing installation has not been changed by creating this repo.
## Start ## Start
```sh ```sh
git clone --branch v0.1.0 https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git git clone --branch v0.1.1 https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
cd Fiw-Gentoo-Dots cd Fiw-Gentoo-Dots
./install ./install
``` ```
@ -47,12 +47,14 @@ configs, kernel/bootloader choices, individual Flatpaks, optional services and
a final preview. Enter saves without applying. `r` runs the full restore; a final preview. Enter saves without applying. `r` runs the full restore;
`a` restores configs, `i` installs Portage packages, `f` installs Flatpaks, `a` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
`v` enables selected services and `b` deploys the selected bootloader. `v` enables selected services and `b` deploys the selected bootloader.
`u` opens config backups for preview and restoration. Apply actions finish `e` updates configs while preserving local edits; `p` opens pending changes
for a diff and individual acceptance. `u` opens config backups for preview
and restoration. Apply actions finish
with a [combined report](docs/reports.md) of outcomes and remaining setup. with a [combined report](docs/reports.md) of outcomes and remaining setup.
Linux amd64 includes a verified prebuilt TUI; Git and Python 3.11+ are enough. Linux amd64 includes a verified prebuilt TUI; Git and Python 3.11+ are enough.
Go 1.24+ is the source fallback for changed frontend code or other platforms. Go 1.24+ is the source fallback for changed frontend code or other platforms.
Saved selections are reused on subsequent launches. See the Saved selections are reused on subsequent launches. See the
[v0.1.0 snapshot and build instructions](docs/releases.md). [snapshot and build instructions](docs/releases.md).
For a preview without building the TUI: For a preview without building the TUI:
@ -125,6 +127,8 @@ is also optional.
Existing differences prompt apply-or-keep and get backups when replaced. Existing differences prompt apply-or-keep and get backups when replaced.
Updates leave locally edited configs intact and write the proposal to `.new`. Updates leave locally edited configs intact and write the proposal to `.new`.
The TUI can [review and accept each proposal](docs/updates.md), backing up the
current file and refusing proposals whose target changed since creation.
KConfig patches preserve unrelated keys and groups; JSON patches preserve KConfig patches preserve unrelated keys and groups; JSON patches preserve
unrelated preferences and login data already on the target device. unrelated preferences and login data already on the target device.
The TUI can [restore user config backups](docs/backups.md), with a preview, The TUI can [restore user config backups](docs/backups.md), with a preview,

View file

@ -1 +1 @@
0.1.0 0.1.1

View file

@ -1 +1 @@
767c35da0b46eaa785fa20b2d008e8403aa64e46223e116228d6b4be91e6dfb5 fiw-dots-linux-amd64.gz a14492188be4cbf1bfdda2f793527b07c0168b2e0d36b7f268922a5cb7b0c08c fiw-dots-linux-amd64.gz

Binary file not shown.

View file

@ -1,12 +1,12 @@
{ {
"version": "0.1.0", "version": "0.1.1",
"source_sha256": "df16d0f9960968f191147aa7c45ff1161b30616f7e545f7a28bae9dc038d026e", "source_sha256": "92a1dc2a662d9ec71c0abec3b96078662268ab16f358be6d2617bbcc4baaf324",
"binaries": { "binaries": {
"linux-amd64": { "linux-amd64": {
"file": "fiw-dots-linux-amd64.gz", "file": "fiw-dots-linux-amd64.gz",
"sha256": "32e133786d350016085741f1e4f15fae174977cb7cd63eae7999f97d3715c59b", "sha256": "6da013a88843912b105f1e2a956cd094b22cfa853b5d9eeed3b2430c7f7ec798",
"gzip_sha256": "767c35da0b46eaa785fa20b2d008e8403aa64e46223e116228d6b4be91e6dfb5", "gzip_sha256": "a14492188be4cbf1bfdda2f793527b07c0168b2e0d36b7f268922a5cb7b0c08c",
"size": 3555490 "size": 3559586
} }
} }
} }

View file

@ -65,6 +65,15 @@ type backupsMsg struct {
choices []backupChoice choices []backupChoice
err error err error
} }
type proposalChoice struct {
ID string `json:"id"`
Target string `json:"target"`
Status string `json:"status"`
}
type proposalsMsg struct {
choices []proposalChoice
err error
}
type model struct { type model struct {
repo string repo string
catalog catalog catalog catalog
@ -73,6 +82,8 @@ type model struct {
stage, cursor, height, width, scroll int stage, cursor, height, width, scroll int
preview, errorText, action, backupID string preview, errorText, action, backupID string
done, loading bool done, loading bool
proposalID, proposalStatus string
proposals []proposalChoice
} }
func contains(values []string, target string) bool { func contains(values []string, target string) bool {
@ -246,6 +257,24 @@ func (m model) backupPreview() tea.Cmd {
return previewMsg{string(output), err} return previewMsg{string(output), err}
} }
} }
func (m model) loadProposals() tea.Cmd {
return func() tea.Msg {
output, err := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--list-proposals").CombinedOutput()
var choices []proposalChoice
if err == nil {
err = json.Unmarshal(output, &choices)
} else {
err = fmt.Errorf("%s", strings.TrimSpace(string(output)))
}
return proposalsMsg{choices, err}
}
}
func (m model) proposalPreview() tea.Cmd {
return func() tea.Msg {
output, err := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--proposal-plan", m.proposalID).CombinedOutput()
return previewMsg{string(output), err}
}
}
func (m model) previewLines() []string { func (m model) previewLines() []string {
width := m.width width := m.width
if width == 0 { if width == 0 {
@ -267,6 +296,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case previewMsg: case previewMsg:
m.loading = false m.loading = false
m.preview = v.content m.preview = v.content
if m.stage == 9 && v.err == nil {
lines := strings.SplitN(v.content, "\n", 4)
m.proposalStatus = ""
if len(lines) > 2 {
m.proposalStatus = strings.TrimPrefix(lines[2], "Status: ")
}
}
if v.err != nil { if v.err != nil {
m.errorText = v.err.Error() m.errorText = v.err.Error()
} }
@ -279,6 +315,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
for _, choice := range v.choices { for _, choice := range v.choices {
m.rows = append(m.rows, row{choice.ID, fmt.Sprintf("%s — %d files", choice.ID, choice.Count), false}) m.rows = append(m.rows, row{choice.ID, fmt.Sprintf("%s — %d files", choice.ID, choice.Count), false})
} }
case proposalsMsg:
m.loading = false
m.rows = nil
m.proposals = v.choices
if v.err != nil {
m.errorText = v.err.Error()
}
for _, choice := range v.choices {
m.rows = append(m.rows, row{choice.ID, choice.ID + " [" + choice.Status + "]", false})
}
case tea.KeyMsg: case tea.KeyMsg:
key := v.String() key := v.String()
if key == "ctrl+c" || key == "q" { if key == "ctrl+c" || key == "q" {
@ -287,7 +333,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 == 5 || m.stage == 7 { if m.stage == 5 || m.stage == 7 || m.stage == 9 {
switch key { switch key {
case "up", "k": case "up", "k":
if m.scroll > 0 { if m.scroll > 0 {
@ -305,6 +351,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.errorText = "" m.errorText = ""
return m, m.loadBackups() return m, m.loadBackups()
} }
if m.stage == 8 {
m.loading = true
m.errorText = ""
return m, m.loadProposals()
}
case "u": case "u":
if m.stage == 5 { if m.stage == 5 {
m.stage = 6 m.stage = 6
@ -313,7 +364,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.errorText = "" m.errorText = ""
return m, m.loadBackups() return m, m.loadBackups()
} }
case "enter", "s", "a", "i", "f", "v", "b", "r": case "p":
if m.stage == 5 {
m.stage = 8
m.prepare()
m.loading = true
m.errorText = ""
return m, m.loadProposals()
}
case "enter", "s", "a", "e", "i", "f", "v", "b", "r":
if m.errorText == "" { if m.errorText == "" {
if m.stage == 7 { if m.stage == 7 {
if key != "enter" { if key != "enter" {
@ -321,6 +380,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
key = "u" key = "u"
} }
if m.stage == 9 {
if key != "enter" || (m.proposalStatus != "ready" && m.proposalStatus != "unchanged") {
return m, nil
}
key = "p"
}
m.done = true m.done = true
m.action = key m.action = key
return m, tea.Quit return m, tea.Quit
@ -328,7 +393,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if m.stage == 6 { if m.stage == 6 || m.stage == 8 {
switch key { switch key {
case "up", "k": case "up", "k":
if m.cursor > 0 { if m.cursor > 0 {
@ -340,6 +405,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "enter": case "enter":
if len(m.rows) > 0 && m.errorText == "" { if len(m.rows) > 0 && m.errorText == "" {
if m.stage == 8 {
m.proposalID = m.rows[m.cursor].id
m.proposalStatus = m.proposals[m.cursor].Status
m.stage = 9
m.scroll = 0
m.loading = true
return m, m.proposalPreview()
}
m.backupID = m.rows[m.cursor].id m.backupID = m.rows[m.cursor].id
m.stage = 7 m.stage = 7
m.scroll = 0 m.scroll = 0
@ -425,13 +498,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", "Flatpaks and optional services", "Final preview", "Config backups", "Restore backup preview"} titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Flatpaks and optional services", "Final preview", "Config backups", "Restore backup preview", "Pending config updates", "Config update diff"}
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 == 5 || m.stage == 7 { if m.stage == 5 || m.stage == 7 || m.stage == 9 {
if m.loading { if m.loading {
return text + " Preparing preview…\n" return text + " Preparing preview…\n"
} }
@ -445,18 +518,27 @@ func (m model) View() string {
} }
if m.stage == 7 { if m.stage == 7 {
text += "\n ↑/↓ scroll · Esc back · Enter restore with confirmation · q cancel\n" text += "\n ↑/↓ scroll · Esc back · Enter restore with confirmation · q cancel\n"
} else if m.stage == 9 {
if m.proposalStatus == "stale" {
text += "\n Target changed: update configs again to create a fresh proposal.\n ↑/↓ scroll · Esc back · q cancel\n"
} else { } else {
text += "\n ↑/↓ scroll · Esc back · Enter save · r full restore · a configs · i packages\n f Flatpaks · v services · b bootloader · u config backups · q cancel\n" text += "\n ↑/↓ scroll · Esc back · Enter accept with confirmation · q cancel\n"
}
} else {
text += "\n ↑/↓ scroll · Esc back · Enter save · r full restore · a configs · e update configs\n i packages · f Flatpaks · v services · b bootloader\n p pending updates · u config backups · q cancel\n"
} }
if m.errorText != "" { if m.errorText != "" {
text += " " + m.errorText + "\n" text += " " + m.errorText + "\n"
} }
return text return text
} }
if m.stage == 6 && (m.loading || len(m.rows) == 0 || m.errorText != "") { if (m.stage == 6 || m.stage == 8) && (m.loading || len(m.rows) == 0 || m.errorText != "") {
message := "No config backups yet. Backups are created when existing configs are replaced." message := "No config backups yet. Backups are created when existing configs are replaced."
if m.stage == 8 {
message = "No pending config updates. Use e from the final preview to update configs."
}
if m.loading { if m.loading {
message = "Loading config backups…" message = "Loading files…"
} }
if m.errorText != "" { if m.errorText != "" {
message = m.errorText message = m.errorText
@ -482,6 +564,9 @@ func (m model) View() string {
cursor = "› " cursor = "› "
} }
line := " " + cursor + mark + " " + r.label line := " " + cursor + mark + " " + r.label
if m.stage == 8 && m.width > 10 {
line = ansi.Truncate(line, m.width-2, "…")
}
if m.cursor == i { if m.cursor == i {
line = purple + line + reset line = purple + line + reset
} }
@ -490,6 +575,9 @@ func (m model) View() string {
if m.stage == 6 { if m.stage == 6 {
return text + "\n ↑/↓ move · Enter preview backup · Esc back · q cancel\n" return text + "\n ↑/↓ move · Enter preview backup · Esc back · q cancel\n"
} }
if m.stage == 8 {
return text + "\n ↑/↓ move · Enter preview diff · Esc back · q cancel\n"
}
return text + "\n ↑/↓ move · Space select · Enter next · Esc back · q cancel\n" return text + "\n ↑/↓ move · Space select · Enter next · Esc back · q cancel\n"
} }
@ -586,6 +674,12 @@ func main() {
case "a": case "a":
workflow = "configs" workflow = "configs"
actionErr = run("--apply-configs", false) actionErr = run("--apply-configs", false)
case "e":
workflow = "configs"
actionErr = run("--apply-configs", false, "--update")
case "p":
workflow = "proposal"
actionErr = run("--accept-proposal", false, final.proposalID)
case "i": case "i":
workflow = "packages" workflow = "packages"
actionErr = run("--install-packages", true) actionErr = run("--install-packages", true)

View file

@ -115,3 +115,51 @@ func TestPreviewCanScrollThroughLongRequirementLines(t *testing.T) {
t.Fatal("wrapped requirements cannot be scrolled into view") t.Fatal("wrapped requirements cannot be scrolled into view")
} }
} }
func TestProposalNeedsDiffAndExplicitAcceptance(t *testing.T) {
m := model{stage: 5}
next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}})
m = next.(model)
if m.stage != 8 || command == nil || m.done {
t.Fatal("proposal key did not open the chooser")
}
next, _ = m.Update(proposalsMsg{choices: []proposalChoice{{ID: ".config/app.new", Target: ".config/app", Status: "ready"}}})
m = next.(model)
next, command = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = next.(model)
if m.stage != 9 || command == nil || m.done {
t.Fatal("choosing a proposal must only open its diff")
}
next, _ = m.Update(previewMsg{content: "Config update proposal: .config/app.new\nTarget: .config/app\nStatus: ready\n\n-preview\n+proposal"})
m = next.(model)
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
m = next.(model)
if m.done {
t.Fatal("application key accepted a proposal")
}
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = next.(model)
if !m.done || m.action != "p" || m.proposalID != ".config/app.new" {
t.Fatal("explicit proposal acceptance was lost")
}
}
func TestProposalThatBecameStaleCannotBeAcceptedFromPreview(t *testing.T) {
m := model{stage: 9, proposalStatus: "ready", loading: true}
next, _ := m.Update(previewMsg{content: "Config update proposal: .config/app.new\nTarget: .config/app\nStatus: stale\n"})
m = next.(model)
next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = next.(model)
if m.done || command != nil || !strings.Contains(m.View(), "Target changed") {
t.Fatal("stale proposal was offered for acceptance")
}
}
func TestConfigUpdateIsASeparateAction(t *testing.T) {
m := model{stage: 5}
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
m = next.(model)
if !m.done || m.action != "e" {
t.Fatal("config update action was lost")
}
}

View file

@ -1,5 +1,25 @@
# Release snapshots # Release snapshots
## v0.1.1 — Config update review
Adds **e** for config updates and **p** for pending changes in the final TUI
preview. Each pending file has a diff and an acceptance prompt. Accepting
backs up the current file and rechecks that it has not changed since the
proposal was created. Manually edited proposals remain local preferences
on future updates. Selected patches for shared KDE files produce one combined
proposal containing both styling and shortcut adjustments.
```sh
git clone --branch v0.1.1 https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots.git
cd Fiw-Gentoo-Dots
./install
```
The matching prebuilt Linux amd64 TUI is included in the
[tag](https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots/src/tag/v0.1.1) and
[source archive](https://git.fiwlabs.dev/fiwdev/Fiw-Gentoo-Dots/archive/v0.1.1.tar.gz).
See [config updates](updates.md) for the review flow and CLI commands.
## v0.1.0 — First restoration snapshot ## v0.1.0 — First restoration snapshot
This tag captures the current KDE shortcuts and appearance, categorized This tag captures the current KDE shortcuts and appearance, categorized

View file

@ -11,7 +11,7 @@ The report includes:
- Installed, existing, skipped or failed user Flatpaks. - Installed, existing, skipped or failed user Flatpaks.
- Enabled, missing or skipped service units. - Enabled, missing or skipped service units.
- Applied configs, preserved conflicts and pending `.new` proposals. - Applied configs, preserved conflicts and pending `.new` proposals.
- Boot deployment outcome or backup restoration outcome. - Boot deployment, backup restoration or config proposal acceptance outcome.
- Missing config requirements and selected manual setup steps. - Missing config requirements and selected manual setup steps.
A cancelled operation stops the full restore sequence. Completed earlier A cancelled operation stops the full restore sequence. Completed earlier
@ -37,6 +37,6 @@ sudo ./install --selection local/selection.json --run-id my-restore --install-pa
``` ```
Run the combined summary as your regular user. `--workflow configs`, Run the combined summary as your regular user. `--workflow configs`,
`packages`, `flatpaks`, `services`, `boot` or `backup` limits the expected steps `packages`, `flatpaks`, `services`, `boot`, `backup` or `proposal` limits the expected steps
when summarizing one action. Full restoration is the default. Without an when summarizing one action. Full restoration is the default. Without an
explicit ID, separate CLI apply commands are separate runs. explicit ID, separate CLI apply commands are separate runs.

View file

@ -8,6 +8,7 @@
| `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend | | `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend |
| `lib/boot.py` | Detects the target ESP/root, previews/deploys Limine or GRUB, refreshes kernel menus | Boot action; copied as `fiw-dots-boot` on deployment | | `lib/boot.py` | Detects the target ESP/root, previews/deploys Limine or GRUB, refreshes kernel menus | Boot action; copied as `fiw-dots-boot` on deployment |
| `lib/backups.py` | Lists and previews user config backups, restores selected files and backs up their current versions | TUI backup action or backend commands | | `lib/backups.py` | Lists and previews user config backups, restores selected files and backs up their current versions | TUI backup action or backend commands |
| `lib/proposals.py` | Lists pending config changes, previews diffs and accepts individual proposals after checking for newer edits | TUI pending updates action or backend commands |
| `lib/reporting.py` | Correlates root/user steps, failures, skips and manual setup into one local summary | Runs after TUI apply actions; `--summary` for CLI use | | `lib/reporting.py` | Correlates root/user steps, failures, skips and manual setup into one local summary | Runs after TUI apply actions; `--summary` for CLI use |
| `fiw-shot` | Captures a region or monitor using Spectacle, copies it to the clipboard and offers Save/Edit through a notification | Installed with KDE shortcuts; `region` or `screen` argument | | `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/capture.py` | Recaptures the explicit preference allowlist without copying full personal profiles | Maintenance command; review the diff |

View file

@ -96,7 +96,8 @@ Ark, Gwenview and Prism also have individually optional preferences. Their
capture scope and the preview's package requirements are described in capture scope and the preview's package requirements are described in
[app configs](app-configs.md). r2modman setup remains a manual step. [app configs](app-configs.md). r2modman setup remains a manual step.
Review Git changes before pulling updates, then restore your saved selection: Review Git changes before pulling updates, then open the TUI with your saved
selection and press **e** at the final preview. The command-line equivalent is:
```sh ```sh
./install --selection local/selection.json --apply-configs --update ./install --selection local/selection.json --apply-configs --update
@ -107,6 +108,7 @@ files. Replacements receive backups. Root Portage file conflicts ask before
replacement and receive backups too. Reports and user backups live under replacement and receive backups too. Reports and user backups live under
`~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under `~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under
`/var/lib/Fiw-Gentoo-Dots`. `/var/lib/Fiw-Gentoo-Dots`.
Use **p** to [review and accept pending config updates](updates.md) individually.
Use the TUI's **u** action to [restore a user config backup](backups.md). Use the TUI's **u** action to [restore a user config backup](backups.md).
## Fiw tools ## Fiw tools

48
docs/updates.md Normal file
View file

@ -0,0 +1,48 @@
# Review config updates
After reviewing and pulling repository changes, launch `./install`. Your
saved selection is reused. At the final preview, press **e** to update the
selected configs. Locally edited files stay intact; the proposed content goes
to a separate `.new` file. Existing proposals are retained, with a timestamp
on subsequent proposals. Unchanged files are left alone. Other replacements
still have apply-or-keep prompts and backups.
Launch the TUI again and press **p** to browse pending proposals. Choose a
file to see its diff, then press Enter to begin the acceptance prompt.
Esc returns to the chooser. Acceptance defaults to keeping the current file.
You can accept each app's changes separately.
Pending files have these states:
| State | Meaning |
|---|---|
| `ready` | The current file matches the version used to generate the proposal. |
| `unchanged` | The current file already matches the proposal; acceptance clears that proposal. |
| `stale` | The current file changed after proposal creation. Update configs again to generate a fresh proposal. |
Acceptance rechecks the current file after confirmation. A newer edit stops
acceptance and preserves both files. A proposal edited during confirmation
also requires another review. Each replaced current file gets a backup
available through **u**. Only the accepted proposal is removed. If you edit a
proposal before accepting it, the resulting config is treated as a local
preference on future updates.
Close the affected app before accepting its config. KDE proposals require
logging out of Plasma and running from a TTY. Shared KDE files combine all
selected patches into one proposal, preserving unrelated keys.
The CLI equivalents are:
```sh
./install --selection local/selection.json --apply-configs --update
./install --list-proposals
./install --proposal-plan '.config/kitty/kitty.conf.new'
./install --accept-proposal '.config/kitty/kitty.conf.new'
```
Use the actual proposal ID shown by the list, including a timestamp if present.
Run these as the target user. The list includes proposals recorded by this
version of the installer. Older or manually created `.new` files remain on
disk; updating again creates a recorded proposal for review without replacing
those files. Proposal records live locally under
`~/.local/state/Fiw-Gentoo-Dots/proposals.json`.

View file

@ -74,7 +74,7 @@ def launch(repo=REPO):
except (KeyError, ValueError, OSError, RuntimeError) as error: except (KeyError, ValueError, OSError, RuntimeError) as error:
print('Prebuilt TUI unavailable: ' + str(error), file=sys.stderr) print('Prebuilt TUI unavailable: ' + str(error), file=sys.stderr)
if not shutil.which('go'): if not shutil.which('go'):
raise RuntimeError('Use the v0.1.0 snapshot for its prebuilt Linux amd64 TUI, or install Go 1.24+ for source builds. Python CLI commands such as ./install --plan remain available.') raise RuntimeError('Use a tagged snapshot for its prebuilt Linux amd64 TUI, or install Go 1.24+ for source builds. Python CLI commands such as ./install --plan remain available.')
print('Building the TUI from source with Go.', file=sys.stderr) print('Building the TUI from source with Go.', file=sys.stderr)
cache.parent.mkdir(parents=True, exist_ok=True) cache.parent.mkdir(parents=True, exist_ok=True)
version = (repo / 'VERSION').read_text().strip() version = (repo / 'VERSION').read_text().strip()

137
lib/proposals.py Normal file
View file

@ -0,0 +1,137 @@
"""Review config update proposals and accept them without losing newer edits."""
import difflib
import json
import os
import re
import shutil
from pathlib import Path
from backups import digest, stamp, user_state
def registry(home):
path = user_state(home) / 'proposals.json'
return json.loads(path.read_text()) if path.is_file() else {}
def save(home, entries):
path = user_state(home) / 'proposals.json'
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name('.proposals-' + stamp())
temporary.write_text(json.dumps(entries, indent=2) + '\n')
temporary.chmod(0o600)
temporary.replace(path)
def record(home, proposal, target, base_hash, configs):
entries = registry(home)
entries[str(proposal)] = {'target': str(target), 'base_sha256': base_hash,
'proposed_sha256': digest(home / proposal),
'configs': configs, 'created': stamp()}
save(home, entries)
def inspect(home, proposal_id, allowed):
home = home.resolve()
entry = registry(home).get(proposal_id)
if not entry or entry.get('target') not in allowed:
raise RuntimeError('Unrecognized config proposal.')
target = entry['target']
if not re.fullmatch(re.escape(target) + r'\.new(?:\.[0-9]{8}T[0-9]{6}\.[0-9]+Z)?', proposal_id):
raise RuntimeError('Invalid config proposal path.')
candidate, destination = home / proposal_id, home / target
for path in (candidate, destination):
if not path.parent.resolve().is_relative_to(home) or (path.is_dir() and not path.is_symlink()):
raise RuntimeError('Unsafe config proposal or destination path.')
if not candidate.is_file() and not candidate.is_symlink():
raise FileNotFoundError('Config proposal no longer exists.')
current = digest(destination)
status = 'unchanged' if current == digest(candidate) else 'ready' if current == entry['base_sha256'] else 'stale'
return entry, candidate, destination, status
def list_proposals(home, allowed):
result = []
for proposal_id in sorted(registry(home)):
# Accepted or manually removed proposal files are not pending.
try:
entry, _, _, status = inspect(home, proposal_id, allowed)
except FileNotFoundError:
continue
result.append({'id': proposal_id, 'target': entry['target'], 'status': status})
return result
def preview(home, proposal_id, allowed):
entry, candidate, destination, status = inspect(home, proposal_id, allowed)
lines = ['Config update proposal: ' + proposal_id, 'Target: ' + entry['target'], 'Status: ' + status, '']
if status == 'stale':
lines += ['The target changed after this proposal was created.',
'Run a config update to generate a fresh proposal. This one will be kept.', '']
if candidate.is_symlink() or destination.is_symlink():
lines += ['Current: ' + str(digest(destination)), 'Proposed: ' + str(digest(candidate))]
else:
current = destination.read_bytes() if destination.is_file() else b''
proposed = candidate.read_bytes()
try:
if b'\0' in current + proposed:
raise UnicodeError
diff = difflib.unified_diff(current.decode().splitlines(), proposed.decode().splitlines(),
fromfile=entry['target'], tofile=proposal_id, lineterm='')
lines += list(diff) or ['No content changes.']
except UnicodeError:
lines += ['Binary file: ' + str(len(current)) + ' -> ' + str(len(proposed)) + ' bytes.',
'Current SHA256: ' + str(digest(destination)), 'Proposed SHA256: ' + str(digest(candidate))]
return '\n'.join(lines)
def accept(home, proposal_id, allowed, ask, active_plasma):
if os.geteuid() == 0:
raise RuntimeError('Accept user config proposals as the target user, not root.')
home = home.resolve()
entry, candidate, destination, status = inspect(home, proposal_id, allowed)
if status == 'stale':
raise RuntimeError('The target changed since this proposal was created. Update configs again; the old proposal is preserved.')
if allowed[entry['target']].get('kde') and active_plasma(home) and status != 'unchanged':
raise RuntimeError('Log out of Plasma before accepting a KDE config proposal.')
proposed_hash = digest(candidate)
print(preview(home, proposal_id, allowed))
report = {'applied': [], 'unchanged': [], 'kept': []}
if ask('Accept this previewed proposal and back up the current file?', ['apply', 'keep'], 'keep') != 'apply':
report['kept'] = [proposal_id]
return report
# Check again after the confirmation in case the app rewrote its config.
entry, candidate, destination, status = inspect(home, proposal_id, allowed)
if status == 'stale':
raise RuntimeError('The target changed during review. The proposal and current file are preserved.')
if digest(candidate) != proposed_hash:
raise RuntimeError('The proposal changed during review. Review it again; both files are preserved.')
state = user_state(home)
timestamp = stamp()
if status != 'unchanged':
backup = state / 'backups' / timestamp / entry['target']
if destination.exists() or destination.is_symlink():
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(destination, backup, follow_symlinks=False)
temporary = destination.with_name('.' + destination.name + '.fiw-proposal-' + timestamp)
shutil.copy2(candidate, temporary, follow_symlinks=False)
os.replace(temporary, destination)
report['applied'] = [entry['target']]
else:
report['unchanged'] = [entry['target']]
index_path = state / 'managed.json'
index = json.loads(index_path.read_text()) if index_path.is_file() else {}
if digest(destination) == entry.get('proposed_sha256'):
index[entry['target']] = digest(destination)
else:
# A manually edited proposal is a local preference on future updates.
index.pop(entry['target'], None)
temporary = index_path.with_name('.managed-proposal-' + timestamp)
temporary.write_text(json.dumps(index, indent=2) + '\n')
temporary.replace(index_path)
entries = registry(home)
entries.pop(proposal_id, None)
save(home, entries)
candidate.unlink()
report['backup'] = timestamp if (state / 'backups' / timestamp).exists() else None
return report

View file

@ -18,6 +18,7 @@ sys.path.insert(0, str(REPO / 'lib'))
from capture import sections from capture import sections
import backups import backups
import reporting import reporting
import proposals
from setup import (FLATPAKS, SERVICES, required_repositories, from setup import (FLATPAKS, SERVICES, required_repositories,
existing_repository, repository_config, stage_repositories, existing_repository, repository_config, stage_repositories,
publish_repositories, install_flatpaks, enable_services) publish_repositories, install_flatpaks, enable_services)
@ -228,7 +229,14 @@ def apply_configs(selection, home, update=False, conflict='ask'):
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 {}
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')
report = {'applied': [], 'unchanged': [], 'kept': [], 'review': [], 'missing': []} report = {'applied': [], 'unchanged': [], 'kept': [], 'review': [], 'missing': []}
for name, kind, src, relative in entries: jobs = {}
for entry in entries:
name, kind, src, relative = entry
if relative in jobs and (kind not in ('kconfig', 'json') or jobs[relative][0][1] != kind):
raise RuntimeError('Conflicting config sources for ' + str(relative))
jobs.setdefault(relative, []).append(entry)
for relative, parts in jobs.items():
name, kind, src, _ = parts[-1]
dest = home / relative dest = home / relative
# Keep writes inside the requested home, including through parent symlinks. # Keep writes inside the requested home, including through parent symlinks.
if not dest.parent.resolve().is_relative_to(home): if not dest.parent.resolve().is_relative_to(home):
@ -248,7 +256,10 @@ def apply_configs(selection, home, update=False, conflict='ask'):
content = content.decode().replace('{{HOME}}', str(home)).encode() content = content.decode().replace('{{HOME}}', str(home)).encode()
if kind in ('kconfig', 'json'): if kind in ('kconfig', 'json'):
old_text = dest.read_text() if dest.is_file() else '' old_text = dest.read_text() if dest.is_file() else ''
content = (merge_kconfig if kind == 'kconfig' else merge_json)(old_text, content.decode()).encode() merge = merge_kconfig if kind == 'kconfig' else merge_json
for _, _, patch, _ in parts:
old_text = merge(old_text, patch.read_text().replace('{{HOME}}', str(home)))
content = old_text.encode()
new_hash = hashlib.sha256(content).hexdigest() new_hash = hashlib.sha256(content).hexdigest()
key = str(relative) key = str(relative)
if old_hash == new_hash: if old_hash == new_hash:
@ -285,6 +296,8 @@ def apply_configs(selection, home, update=False, conflict='ask'):
if target == dest: if target == dest:
index[key] = new_hash index[key] = new_hash
report['applied'].append(key) report['applied'].append(key)
else:
proposals.record(home, target.relative_to(home), relative, old_hash, [part[0] for part in parts])
state_dir.mkdir(parents=True, exist_ok=True) state_dir.mkdir(parents=True, exist_ok=True)
temporary = index_path.with_name('.managed-' + stamp) temporary = index_path.with_name('.managed-' + stamp)
temporary.write_text(json.dumps(index, indent=2) + '\n') temporary.write_text(json.dumps(index, indent=2) + '\n')
@ -597,10 +610,13 @@ def main():
parser.add_argument('--list-backups', action='store_true') parser.add_argument('--list-backups', action='store_true')
parser.add_argument('--backup-plan', metavar='ID') parser.add_argument('--backup-plan', metavar='ID')
parser.add_argument('--restore-backup', metavar='ID') parser.add_argument('--restore-backup', metavar='ID')
parser.add_argument('--list-proposals', action='store_true')
parser.add_argument('--proposal-plan', metavar='ID')
parser.add_argument('--accept-proposal', metavar='ID')
parser.add_argument('--summary', action='store_true') parser.add_argument('--summary', action='store_true')
parser.add_argument('--run-id') parser.add_argument('--run-id')
parser.add_argument('--execution-error', help=argparse.SUPPRESS) parser.add_argument('--execution-error', help=argparse.SUPPRESS)
parser.add_argument('--workflow', choices=['restore', 'configs', 'packages', 'flatpaks', 'services', 'boot', 'backup'], default='restore') parser.add_argument('--workflow', choices=['restore', 'configs', 'packages', 'flatpaks', 'services', 'boot', 'backup', 'proposal'], default='restore')
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()
@ -613,6 +629,12 @@ def main():
if args.run_id: if args.run_id:
reporting.validate_run(args.run_id) reporting.validate_run(args.run_id)
home = args.home.resolve() home = args.home.resolve()
if args.list_proposals:
print(json.dumps(proposals.list_proposals(home, backup_paths())))
return
if args.proposal_plan:
print(proposals.preview(home, args.proposal_plan, backup_paths()))
return
if args.list_backups: if args.list_backups:
print(json.dumps(backups.list_backups(home, backup_paths()))) print(json.dumps(backups.list_backups(home, backup_paths())))
return return
@ -627,7 +649,7 @@ def main():
action = ('configs' if args.apply_configs else 'packages' if args.install_packages else action = ('configs' if args.apply_configs else 'packages' if args.install_packages else
'flatpaks' if args.install_flatpaks else 'services-system' if args.enable_services and os.geteuid() == 0 else 'flatpaks' if args.install_flatpaks else 'services-system' if args.enable_services and os.geteuid() == 0 else
'services-user' if args.enable_services else 'boot' if args.deploy_bootloader else 'services-user' if args.enable_services else 'boot' if args.deploy_bootloader else
'backup' if args.restore_backup else 'preflight' if args.check_configs and args.run_id else None) 'backup' if args.restore_backup else 'proposal' if args.accept_proposal else 'preflight' if args.check_configs and args.run_id else None)
if action: if action:
run_id = args.run_id or datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') run_id = args.run_id or datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
details = {} details = {}
@ -647,6 +669,8 @@ def main():
details = deploy(selection['bootloader'], args.esp, ask) details = deploy(selection['bootloader'], args.esp, ask)
elif action == 'backup': elif action == 'backup':
details = backups.restore(home, args.restore_backup, backup_paths(), ask, active_plasma, args.conflict) details = backups.restore(home, args.restore_backup, backup_paths(), ask, active_plasma, args.conflict)
elif action == 'proposal':
details = proposals.accept(home, args.accept_proposal, backup_paths(), ask, active_plasma)
else: else:
check_configs(selection, home) check_configs(selection, home)
print('Config restoration preflight passed.') print('Config restoration preflight passed.')

131
tests/test_proposals.py Normal file
View file

@ -0,0 +1,131 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
rice = importlib.util.module_from_spec(spec)
spec.loader.exec_module(rice)
class ProposalTests(unittest.TestCase):
def create(self, home):
selection = rice.load_selection()
selection.update(groups=[], configs=['fastfetch'])
rice.apply_configs(selection, home, conflict='apply')
target = home / '.config/fastfetch/config.jsonc'
target.write_text('local preferences\n')
rice.apply_configs(selection, home, update=True, conflict='apply')
candidate = Path(str(target) + '.new')
return selection, target, candidate, candidate.relative_to(home).as_posix()
def test_accept_backs_up_local_file_and_keeps_other_proposals(self):
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
home = Path(temp)
selection, target, candidate, proposal_id = self.create(home)
proposed = candidate.read_bytes()
rice.apply_configs(selection, home, update=True, conflict='apply')
pending = rice.proposals.list_proposals(home, rice.backup_paths())
self.assertEqual(len(pending), 2)
self.assertIn('-local preferences', rice.proposals.preview(home, proposal_id, rice.backup_paths()))
report = rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
self.assertEqual(target.read_bytes(), proposed)
backup = rice.backups.user_state(home) / 'backups' / report['backup'] / '.config/fastfetch/config.jsonc'
self.assertEqual(backup.read_text(), 'local preferences\n')
self.assertFalse(candidate.exists())
pending = rice.proposals.list_proposals(home, rice.backup_paths())
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0]['status'], 'unchanged')
index = json.loads((rice.backups.user_state(home) / 'managed.json').read_text())
self.assertEqual(index['.config/fastfetch/config.jsonc'], rice.digest(target))
def test_keep_and_stale_targets_preserve_both_files(self):
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
home = Path(temp)
_, target, candidate, proposal_id = self.create(home)
original_proposal = candidate.read_bytes()
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'keep', lambda *args: False)
self.assertEqual(target.read_text(), 'local preferences\n')
self.assertEqual(candidate.read_bytes(), original_proposal)
target.write_text('newer local preferences\n')
with self.assertRaisesRegex(RuntimeError, 'changed since'):
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
self.assertEqual(target.read_text(), 'newer local preferences\n')
self.assertEqual(candidate.read_bytes(), original_proposal)
def test_target_change_during_confirmation_is_rejected(self):
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
home = Path(temp)
_, target, candidate, proposal_id = self.create(home)
def confirm(*args):
target.write_text('app rewrote its config\n')
return 'apply'
with self.assertRaisesRegex(RuntimeError, 'changed during'):
rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
self.assertEqual(target.read_text(), 'app rewrote its config\n')
self.assertTrue(candidate.is_file())
def test_manually_edited_proposal_remains_local_on_future_updates(self):
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
home = Path(temp)
selection, target, candidate, proposal_id = self.create(home)
candidate.write_text('chosen custom config\n')
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
rice.apply_configs(selection, home, update=True, conflict='apply')
self.assertEqual(target.read_text(), 'chosen custom config\n')
self.assertTrue(candidate.is_file())
def test_proposal_change_during_confirmation_requires_another_review(self):
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
home = Path(temp)
_, target, candidate, proposal_id = self.create(home)
def confirm(*args):
candidate.write_text('edited proposal during review\n')
return 'apply'
with self.assertRaisesRegex(RuntimeError, 'proposal changed during'):
rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
self.assertEqual(target.read_text(), 'local preferences\n')
self.assertEqual(candidate.read_text(), 'edited proposal during review\n')
def test_paths_outside_home_and_live_kde_are_rejected(self):
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
root = Path(temp); home = root / 'home'; home.mkdir()
outside = root / 'outside'; outside.mkdir()
_, target, candidate, proposal_id = self.create(home)
config = home / '.config'
config.rename(home / 'saved-config')
config.symlink_to(outside, target_is_directory=True)
with self.assertRaisesRegex(RuntimeError, 'Unsafe'):
rice.proposals.list_proposals(home, rice.backup_paths())
config.unlink(); (home / 'saved-config').rename(config)
allowed = rice.backup_paths()
allowed['.config/fastfetch/config.jsonc']['kde'] = True
with self.assertRaisesRegex(RuntimeError, 'Log out'):
rice.proposals.accept(home, proposal_id, allowed, lambda *args: 'apply', lambda *args: True)
self.assertEqual(target.read_text(), 'local preferences\n')
self.assertTrue(candidate.is_file())
def test_shared_kconfig_generates_one_complete_proposal(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp); home = root / 'home'; home.mkdir()
relative = Path('.config/kwinrc')
target = home / relative; target.parent.mkdir()
target.write_text('[Appearance]\ncolour=orange\n[Desktops]\nNumber=4\n[Other]\nkeep=yes\n')
style, shortcuts = root / 'style', root / 'shortcuts'
style.write_text('[Appearance]\ncolour=purple\n')
shortcuts.write_text('[Desktops]\nNumber=1\n')
entries = [('kde-style', 'kconfig', style, relative), ('kde-shortcuts', 'kconfig', shortcuts, relative)]
with patch.object(rice, 'config_entries', return_value=entries):
rice.apply_configs(rice.load_selection(), home, update=True, conflict='apply')
choices = rice.proposals.list_proposals(home, rice.backup_paths())
self.assertEqual(len(choices), 1)
content = (home / choices[0]['id']).read_text()
for value in ('colour=purple', 'Number=1', 'keep=yes'):
self.assertIn(value, content)
self.assertIn('colour=orange', target.read_text())
if __name__ == '__main__':
unittest.main()