diff --git a/README.md b/README.md index c9845ed..10d09b1 100644 --- a/README.md +++ b/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, diff --git a/VERSION b/VERSION index 6e8bf73..17e51c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/assets/bin/SHA256SUMS b/assets/bin/SHA256SUMS index b07b927..ef1f8c3 100644 --- a/assets/bin/SHA256SUMS +++ b/assets/bin/SHA256SUMS @@ -1 +1 @@ -767c35da0b46eaa785fa20b2d008e8403aa64e46223e116228d6b4be91e6dfb5 fiw-dots-linux-amd64.gz +a14492188be4cbf1bfdda2f793527b07c0168b2e0d36b7f268922a5cb7b0c08c fiw-dots-linux-amd64.gz diff --git a/assets/bin/fiw-dots-linux-amd64.gz b/assets/bin/fiw-dots-linux-amd64.gz index 08ae55c..6c7b2cc 100644 Binary files a/assets/bin/fiw-dots-linux-amd64.gz and b/assets/bin/fiw-dots-linux-amd64.gz differ diff --git a/assets/bin/tui.json b/assets/bin/tui.json index 93ded05..29d7c63 100644 --- a/assets/bin/tui.json +++ b/assets/bin/tui.json @@ -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 } } } diff --git a/cmd/dots/main.go b/cmd/dots/main.go index 07d04f7..2edbb7d 100644 --- a/cmd/dots/main.go +++ b/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 accept with confirmation · 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 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) diff --git a/cmd/dots/main_test.go b/cmd/dots/main_test.go index 07c3c18..e62e240 100644 --- a/cmd/dots/main_test.go +++ b/cmd/dots/main_test.go @@ -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") + } +} diff --git a/docs/releases.md b/docs/releases.md index acc2fe5..f9fdede 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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 diff --git a/docs/reports.md b/docs/reports.md index b6dda40..6c1a903 100644 --- a/docs/reports.md +++ b/docs/reports.md @@ -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. diff --git a/docs/scripts.md b/docs/scripts.md index 623991f..c1f994e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -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 | diff --git a/docs/setup.md b/docs/setup.md index 611703b..1b9618e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -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 diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..0029a1c --- /dev/null +++ b/docs/updates.md @@ -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`. diff --git a/lib/launcher.py b/lib/launcher.py index 2949c23..7c16bea 100644 --- a/lib/launcher.py +++ b/lib/launcher.py @@ -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() diff --git a/lib/proposals.py b/lib/proposals.py new file mode 100644 index 0000000..1508354 --- /dev/null +++ b/lib/proposals.py @@ -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 diff --git a/lib/rice.py b/lib/rice.py index 760fe25..69fadf9 100644 --- a/lib/rice.py +++ b/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.') diff --git a/tests/test_proposals.py b/tests/test_proposals.py new file mode 100644 index 0000000..9ce7e27 --- /dev/null +++ b/tests/test_proposals.py @@ -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()