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
|
## 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,
|
||||||
|
|
|
||||||
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",
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
cmd/dots/main.go
110
cmd/dots/main.go
|
|
@ -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 {
|
||||||
|
text += "\n ↑/↓ scroll · Esc back · Enter accept with confirmation · 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 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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
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:
|
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
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
|
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
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