Add config update review and publish v0.1.1 bundle
This commit is contained in:
parent
a8fd7c253d
commit
23ab2f59e4
16 changed files with 535 additions and 26 deletions
10
README.md
10
README.md
|
|
@ -37,7 +37,7 @@ The existing installation has not been changed by creating this repo.
|
|||
## Start
|
||||
|
||||
```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
|
||||
./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` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
|
||||
`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.
|
||||
Linux amd64 includes a verified prebuilt TUI; Git and Python 3.11+ are enough.
|
||||
Go 1.24+ is the source fallback for changed frontend code or other platforms.
|
||||
Saved selections are reused on subsequent launches. See the
|
||||
[v0.1.0 snapshot and build instructions](docs/releases.md).
|
||||
[snapshot and build instructions](docs/releases.md).
|
||||
|
||||
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.
|
||||
Updates leave locally edited configs intact and write the proposal to `.new`.
|
||||
The TUI can [review and accept each proposal](docs/updates.md), backing up the
|
||||
current file and refusing proposals whose target changed since creation.
|
||||
KConfig patches preserve unrelated keys and groups; JSON patches preserve
|
||||
unrelated preferences and login data already on the target device.
|
||||
The TUI can [restore user config backups](docs/backups.md), with a preview,
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.1.0
|
||||
0.1.1
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
767c35da0b46eaa785fa20b2d008e8403aa64e46223e116228d6b4be91e6dfb5 fiw-dots-linux-amd64.gz
|
||||
a14492188be4cbf1bfdda2f793527b07c0168b2e0d36b7f268922a5cb7b0c08c fiw-dots-linux-amd64.gz
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"version": "0.1.0",
|
||||
"source_sha256": "df16d0f9960968f191147aa7c45ff1161b30616f7e545f7a28bae9dc038d026e",
|
||||
"version": "0.1.1",
|
||||
"source_sha256": "92a1dc2a662d9ec71c0abec3b96078662268ab16f358be6d2617bbcc4baaf324",
|
||||
"binaries": {
|
||||
"linux-amd64": {
|
||||
"file": "fiw-dots-linux-amd64.gz",
|
||||
"sha256": "32e133786d350016085741f1e4f15fae174977cb7cd63eae7999f97d3715c59b",
|
||||
"gzip_sha256": "767c35da0b46eaa785fa20b2d008e8403aa64e46223e116228d6b4be91e6dfb5",
|
||||
"size": 3555490
|
||||
"sha256": "6da013a88843912b105f1e2a956cd094b22cfa853b5d9eeed3b2430c7f7ec798",
|
||||
"gzip_sha256": "a14492188be4cbf1bfdda2f793527b07c0168b2e0d36b7f268922a5cb7b0c08c",
|
||||
"size": 3559586
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
cmd/dots/main.go
110
cmd/dots/main.go
|
|
@ -65,6 +65,15 @@ type backupsMsg struct {
|
|||
choices []backupChoice
|
||||
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 {
|
||||
repo string
|
||||
catalog catalog
|
||||
|
|
@ -73,6 +82,8 @@ type model struct {
|
|||
stage, cursor, height, width, scroll int
|
||||
preview, errorText, action, backupID string
|
||||
done, loading bool
|
||||
proposalID, proposalStatus string
|
||||
proposals []proposalChoice
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
|
|
@ -246,6 +257,24 @@ func (m model) backupPreview() tea.Cmd {
|
|||
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 {
|
||||
width := m.width
|
||||
if width == 0 {
|
||||
|
|
@ -267,6 +296,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case previewMsg:
|
||||
m.loading = false
|
||||
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 {
|
||||
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 {
|
||||
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:
|
||||
key := v.String()
|
||||
if key == "ctrl+c" || key == "q" {
|
||||
|
|
@ -287,7 +333,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
if m.loading {
|
||||
return m, nil
|
||||
}
|
||||
if m.stage == 5 || m.stage == 7 {
|
||||
if m.stage == 5 || m.stage == 7 || m.stage == 9 {
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if m.scroll > 0 {
|
||||
|
|
@ -305,6 +351,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.errorText = ""
|
||||
return m, m.loadBackups()
|
||||
}
|
||||
if m.stage == 8 {
|
||||
m.loading = true
|
||||
m.errorText = ""
|
||||
return m, m.loadProposals()
|
||||
}
|
||||
case "u":
|
||||
if m.stage == 5 {
|
||||
m.stage = 6
|
||||
|
|
@ -313,7 +364,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.errorText = ""
|
||||
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.stage == 7 {
|
||||
if key != "enter" {
|
||||
|
|
@ -321,6 +380,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
key = "u"
|
||||
}
|
||||
if m.stage == 9 {
|
||||
if key != "enter" || (m.proposalStatus != "ready" && m.proposalStatus != "unchanged") {
|
||||
return m, nil
|
||||
}
|
||||
key = "p"
|
||||
}
|
||||
m.done = true
|
||||
m.action = key
|
||||
return m, tea.Quit
|
||||
|
|
@ -328,7 +393,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.stage == 6 {
|
||||
if m.stage == 6 || m.stage == 8 {
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
|
|
@ -340,6 +405,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case "enter":
|
||||
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.stage = 7
|
||||
m.scroll = 0
|
||||
|
|
@ -425,13 +498,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
func (m model) View() string {
|
||||
const purple = "\033[38;2;157;143;217m"
|
||||
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"
|
||||
budget := m.height - 10
|
||||
if budget < 5 {
|
||||
budget = 5
|
||||
}
|
||||
if m.stage == 5 || m.stage == 7 {
|
||||
if m.stage == 5 || m.stage == 7 || m.stage == 9 {
|
||||
if m.loading {
|
||||
return text + " Preparing preview…\n"
|
||||
}
|
||||
|
|
@ -445,18 +518,27 @@ func (m model) View() string {
|
|||
}
|
||||
if m.stage == 7 {
|
||||
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 {
|
||||
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 != "" {
|
||||
text += " " + m.errorText + "\n"
|
||||
}
|
||||
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."
|
||||
if m.stage == 8 {
|
||||
message = "No pending config updates. Use e from the final preview to update configs."
|
||||
}
|
||||
if m.loading {
|
||||
message = "Loading config backups…"
|
||||
message = "Loading files…"
|
||||
}
|
||||
if m.errorText != "" {
|
||||
message = m.errorText
|
||||
|
|
@ -482,6 +564,9 @@ func (m model) View() string {
|
|||
cursor = "› "
|
||||
}
|
||||
line := " " + cursor + mark + " " + r.label
|
||||
if m.stage == 8 && m.width > 10 {
|
||||
line = ansi.Truncate(line, m.width-2, "…")
|
||||
}
|
||||
if m.cursor == i {
|
||||
line = purple + line + reset
|
||||
}
|
||||
|
|
@ -490,6 +575,9 @@ func (m model) View() string {
|
|||
if m.stage == 6 {
|
||||
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"
|
||||
}
|
||||
|
||||
|
|
@ -586,6 +674,12 @@ func main() {
|
|||
case "a":
|
||||
workflow = "configs"
|
||||
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":
|
||||
workflow = "packages"
|
||||
actionErr = run("--install-packages", true)
|
||||
|
|
|
|||
|
|
@ -115,3 +115,51 @@ func TestPreviewCanScrollThroughLongRequirementLines(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,25 @@
|
|||
# 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
|
||||
|
||||
This tag captures the current KDE shortcuts and appearance, categorized
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ The report includes:
|
|||
- Installed, existing, skipped or failed user Flatpaks.
|
||||
- Enabled, missing or skipped service units.
|
||||
- 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.
|
||||
|
||||
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`,
|
||||
`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
|
||||
explicit ID, separate CLI apply commands are separate runs.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
| `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend |
|
||||
| `lib/boot.py` | Detects the target ESP/root, previews/deploys Limine or GRUB, refreshes kernel menus | Boot action; copied as `fiw-dots-boot` on deployment |
|
||||
| `lib/backups.py` | Lists and previews user config backups, restores selected files and backs up their current versions | TUI backup action or backend commands |
|
||||
| `lib/proposals.py` | Lists pending config changes, previews diffs and accepts individual proposals after checking for newer edits | TUI pending updates action or backend commands |
|
||||
| `lib/reporting.py` | Correlates root/user steps, failures, skips and manual setup into one local summary | Runs after TUI apply actions; `--summary` for CLI use |
|
||||
| `fiw-shot` | Captures a region or monitor using Spectacle, copies it to the clipboard and offers Save/Edit through a notification | Installed with KDE shortcuts; `region` or `screen` argument |
|
||||
| `tools/capture.py` | Recaptures the explicit preference allowlist without copying full personal profiles | Maintenance command; review the diff |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
[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
|
||||
./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
|
||||
`~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under
|
||||
`/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).
|
||||
|
||||
## Fiw tools
|
||||
|
|
|
|||
48
docs/updates.md
Normal file
48
docs/updates.md
Normal 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`.
|
||||
|
|
@ -74,7 +74,7 @@ def launch(repo=REPO):
|
|||
except (KeyError, ValueError, OSError, RuntimeError) as error:
|
||||
print('Prebuilt TUI unavailable: ' + str(error), file=sys.stderr)
|
||||
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)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
version = (repo / 'VERSION').read_text().strip()
|
||||
|
|
|
|||
137
lib/proposals.py
Normal file
137
lib/proposals.py
Normal 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
|
||||
32
lib/rice.py
32
lib/rice.py
|
|
@ -18,6 +18,7 @@ sys.path.insert(0, str(REPO / 'lib'))
|
|||
from capture import sections
|
||||
import backups
|
||||
import reporting
|
||||
import proposals
|
||||
from setup import (FLATPAKS, SERVICES, required_repositories,
|
||||
existing_repository, repository_config, stage_repositories,
|
||||
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 {}
|
||||
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||
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
|
||||
# Keep writes inside the requested home, including through parent symlinks.
|
||||
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()
|
||||
if kind in ('kconfig', 'json'):
|
||||
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()
|
||||
key = str(relative)
|
||||
if old_hash == new_hash:
|
||||
|
|
@ -285,6 +296,8 @@ def apply_configs(selection, home, update=False, conflict='ask'):
|
|||
if target == dest:
|
||||
index[key] = new_hash
|
||||
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)
|
||||
temporary = index_path.with_name('.managed-' + stamp)
|
||||
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('--backup-plan', 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('--run-id')
|
||||
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('--conflict', choices=['ask', 'keep', 'apply'], default='ask')
|
||||
args = parser.parse_args()
|
||||
|
|
@ -613,6 +629,12 @@ def main():
|
|||
if args.run_id:
|
||||
reporting.validate_run(args.run_id)
|
||||
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:
|
||||
print(json.dumps(backups.list_backups(home, backup_paths())))
|
||||
return
|
||||
|
|
@ -627,7 +649,7 @@ def main():
|
|||
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
|
||||
'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:
|
||||
run_id = args.run_id or datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||
details = {}
|
||||
|
|
@ -647,6 +669,8 @@ def main():
|
|||
details = deploy(selection['bootloader'], args.esp, ask)
|
||||
elif action == 'backup':
|
||||
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:
|
||||
check_configs(selection, home)
|
||||
print('Config restoration preflight passed.')
|
||||
|
|
|
|||
131
tests/test_proposals.py
Normal file
131
tests/test_proposals.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py')
|
||||
rice = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rice)
|
||||
|
||||
|
||||
class ProposalTests(unittest.TestCase):
|
||||
def create(self, home):
|
||||
selection = rice.load_selection()
|
||||
selection.update(groups=[], configs=['fastfetch'])
|
||||
rice.apply_configs(selection, home, conflict='apply')
|
||||
target = home / '.config/fastfetch/config.jsonc'
|
||||
target.write_text('local preferences\n')
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
candidate = Path(str(target) + '.new')
|
||||
return selection, target, candidate, candidate.relative_to(home).as_posix()
|
||||
|
||||
def test_accept_backs_up_local_file_and_keeps_other_proposals(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
selection, target, candidate, proposal_id = self.create(home)
|
||||
proposed = candidate.read_bytes()
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
pending = rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
self.assertEqual(len(pending), 2)
|
||||
self.assertIn('-local preferences', rice.proposals.preview(home, proposal_id, rice.backup_paths()))
|
||||
report = rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
|
||||
self.assertEqual(target.read_bytes(), proposed)
|
||||
backup = rice.backups.user_state(home) / 'backups' / report['backup'] / '.config/fastfetch/config.jsonc'
|
||||
self.assertEqual(backup.read_text(), 'local preferences\n')
|
||||
self.assertFalse(candidate.exists())
|
||||
pending = rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
self.assertEqual(len(pending), 1)
|
||||
self.assertEqual(pending[0]['status'], 'unchanged')
|
||||
index = json.loads((rice.backups.user_state(home) / 'managed.json').read_text())
|
||||
self.assertEqual(index['.config/fastfetch/config.jsonc'], rice.digest(target))
|
||||
|
||||
def test_keep_and_stale_targets_preserve_both_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
original_proposal = candidate.read_bytes()
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'keep', lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'local preferences\n')
|
||||
self.assertEqual(candidate.read_bytes(), original_proposal)
|
||||
target.write_text('newer local preferences\n')
|
||||
with self.assertRaisesRegex(RuntimeError, 'changed since'):
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'newer local preferences\n')
|
||||
self.assertEqual(candidate.read_bytes(), original_proposal)
|
||||
|
||||
def test_target_change_during_confirmation_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
def confirm(*args):
|
||||
target.write_text('app rewrote its config\n')
|
||||
return 'apply'
|
||||
with self.assertRaisesRegex(RuntimeError, 'changed during'):
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'app rewrote its config\n')
|
||||
self.assertTrue(candidate.is_file())
|
||||
|
||||
def test_manually_edited_proposal_remains_local_on_future_updates(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
selection, target, candidate, proposal_id = self.create(home)
|
||||
candidate.write_text('chosen custom config\n')
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), lambda *args: 'apply', lambda *args: False)
|
||||
rice.apply_configs(selection, home, update=True, conflict='apply')
|
||||
self.assertEqual(target.read_text(), 'chosen custom config\n')
|
||||
self.assertTrue(candidate.is_file())
|
||||
|
||||
def test_proposal_change_during_confirmation_requires_another_review(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
home = Path(temp)
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
def confirm(*args):
|
||||
candidate.write_text('edited proposal during review\n')
|
||||
return 'apply'
|
||||
with self.assertRaisesRegex(RuntimeError, 'proposal changed during'):
|
||||
rice.proposals.accept(home, proposal_id, rice.backup_paths(), confirm, lambda *args: False)
|
||||
self.assertEqual(target.read_text(), 'local preferences\n')
|
||||
self.assertEqual(candidate.read_text(), 'edited proposal during review\n')
|
||||
|
||||
def test_paths_outside_home_and_live_kde_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(rice.proposals.os, 'geteuid', return_value=1000):
|
||||
root = Path(temp); home = root / 'home'; home.mkdir()
|
||||
outside = root / 'outside'; outside.mkdir()
|
||||
_, target, candidate, proposal_id = self.create(home)
|
||||
config = home / '.config'
|
||||
config.rename(home / 'saved-config')
|
||||
config.symlink_to(outside, target_is_directory=True)
|
||||
with self.assertRaisesRegex(RuntimeError, 'Unsafe'):
|
||||
rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
config.unlink(); (home / 'saved-config').rename(config)
|
||||
allowed = rice.backup_paths()
|
||||
allowed['.config/fastfetch/config.jsonc']['kde'] = True
|
||||
with self.assertRaisesRegex(RuntimeError, 'Log out'):
|
||||
rice.proposals.accept(home, proposal_id, allowed, lambda *args: 'apply', lambda *args: True)
|
||||
self.assertEqual(target.read_text(), 'local preferences\n')
|
||||
self.assertTrue(candidate.is_file())
|
||||
|
||||
def test_shared_kconfig_generates_one_complete_proposal(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp); home = root / 'home'; home.mkdir()
|
||||
relative = Path('.config/kwinrc')
|
||||
target = home / relative; target.parent.mkdir()
|
||||
target.write_text('[Appearance]\ncolour=orange\n[Desktops]\nNumber=4\n[Other]\nkeep=yes\n')
|
||||
style, shortcuts = root / 'style', root / 'shortcuts'
|
||||
style.write_text('[Appearance]\ncolour=purple\n')
|
||||
shortcuts.write_text('[Desktops]\nNumber=1\n')
|
||||
entries = [('kde-style', 'kconfig', style, relative), ('kde-shortcuts', 'kconfig', shortcuts, relative)]
|
||||
with patch.object(rice, 'config_entries', return_value=entries):
|
||||
rice.apply_configs(rice.load_selection(), home, update=True, conflict='apply')
|
||||
choices = rice.proposals.list_proposals(home, rice.backup_paths())
|
||||
self.assertEqual(len(choices), 1)
|
||||
content = (home / choices[0]['id']).read_text()
|
||||
for value in ('colour=purple', 'Number=1', 'keep=yes'):
|
||||
self.assertIn(value, content)
|
||||
self.assertIn('colour=orange', target.read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Add table
Reference in a new issue