Fiw-Gentoo-Dots/cmd/dots/main.go

735 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
var tuiVersion = "source"
type selection struct {
Name string `json:"name"`
Profile string `json:"profile"`
Groups []string `json:"groups"`
Configs []string `json:"configs"`
Kernel string `json:"kernel"`
Bootloader string `json:"bootloader"`
Extras []string `json:"extras"`
Firefox bool `json:"firefox_privacy"`
Spotify bool `json:"spotify_custom"`
Flatpaks []string `json:"flatpaks"`
Services []string `json:"services"`
}
type config struct {
Label string `json:"label"`
Group string `json:"group"`
}
type setupOption struct {
Label string `json:"label"`
Group string `json:"group"`
Scope string `json:"scope"`
}
type catalog struct {
Groups []string `json:"groups"`
Configs map[string]config `json:"configs"`
Extras map[string]string `json:"extras"`
Flatpaks map[string]setupOption `json:"flatpaks"`
Services map[string]setupOption `json:"services"`
Stock selection `json:"stock"`
Ryzen selection `json:"ryzen"`
}
type row struct {
id, label string
checked bool
}
type previewMsg struct {
content string
err error
}
type backupChoice struct {
ID string `json:"id"`
Count int `json:"count"`
}
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
selection selection
rows []row
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 {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func (m model) Init() tea.Cmd { return nil }
func (m *model) prepare() {
m.cursor, m.scroll = 0, 0
m.rows = nil
switch m.stage {
case 0:
m.rows = []row{{"stock", "Stock — binary kernel, portable build settings", m.selection.Profile == "stock"}, {"fiw-ryzen", "Fiw's Ryzen — current Zen 5 tuning, custom + binary fallback", m.selection.Profile == "fiw-ryzen"}}
if m.selection.Profile == "fiw-ryzen" {
m.cursor = 1
}
case 1:
for _, id := range m.catalog.Groups {
label := id
if id == "tidewm" {
label += " (optional)"
}
m.rows = append(m.rows, row{id, label, contains(m.selection.Groups, id)})
}
case 2:
ids := make([]string, 0, len(m.catalog.Configs))
for id := range m.catalog.Configs {
if id != "autostart" {
ids = append(ids, id)
}
}
sort.Strings(ids)
for _, id := range ids {
c := m.catalog.Configs[id]
m.rows = append(m.rows, row{id, c.Label, contains(m.selection.Configs, id)})
}
case 3:
m.rows = []row{
{"binary", "Kernel: generic binary", m.selection.Kernel == "binary"},
{"custom", "Kernel: custom + generic binary fallback", m.selection.Kernel == "custom"},
{"keep", "Bootloader: keep existing", m.selection.Bootloader == "keep"},
{"limine", "Bootloader: optional Limine", m.selection.Bootloader == "limine"},
{"grub", "Bootloader: optional GRUB", m.selection.Bootloader == "grub"},
}
for _, id := range []string{"nvidia", "btrfs", "filelight"} {
m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)})
}
m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify})
case 4:
flatpaks := make([]string, 0, len(m.catalog.Flatpaks))
for id := range m.catalog.Flatpaks {
flatpaks = append(flatpaks, id)
}
sort.Strings(flatpaks)
for _, id := range flatpaks {
option := m.catalog.Flatpaks[id]
m.rows = append(m.rows, row{"flatpak:" + id, "Flatpak: " + option.Label + " (" + option.Group + ")", contains(m.selection.Flatpaks, id)})
}
for _, id := range []string{"audio", "network", "bluetooth", "power"} {
if option, ok := m.catalog.Services[id]; ok {
m.rows = append(m.rows, row{"service:" + id, option.Label, contains(m.selection.Services, id)})
}
}
}
}
func (m *model) remember() {
switch m.stage {
case 1:
m.selection.Groups = []string{}
for _, r := range m.rows {
if r.checked {
m.selection.Groups = append(m.selection.Groups, r.id)
}
}
case 2:
autostart := contains(m.selection.Configs, "autostart")
m.selection.Configs = []string{}
for _, r := range m.rows {
if r.checked {
m.selection.Configs = append(m.selection.Configs, r.id)
}
}
if autostart {
m.selection.Configs = append(m.selection.Configs, "autostart")
}
case 3:
m.selection.Extras = []string{}
for _, r := range m.rows {
if _, ok := m.catalog.Extras[r.id]; ok && r.checked {
m.selection.Extras = append(m.selection.Extras, r.id)
}
if r.checked && (r.id == "binary" || r.id == "custom") {
m.selection.Kernel = r.id
}
if r.checked && (r.id == "keep" || r.id == "limine" || r.id == "grub") {
m.selection.Bootloader = r.id
}
if r.id == "firefox" {
m.selection.Firefox = r.checked
}
if r.id == "spotify" {
m.selection.Spotify = r.checked
}
}
configNames := []string{}
for _, id := range m.selection.Configs {
if id != "autostart" {
configNames = append(configNames, id)
}
}
for _, r := range m.rows {
if r.id == "autostart" && r.checked {
configNames = append(configNames, "autostart")
}
}
m.selection.Configs = configNames
case 4:
m.selection.Flatpaks, m.selection.Services = []string{}, []string{}
for _, r := range m.rows {
if !r.checked {
continue
}
if strings.HasPrefix(r.id, "flatpak:") {
m.selection.Flatpaks = append(m.selection.Flatpaks, strings.TrimPrefix(r.id, "flatpak:"))
}
if strings.HasPrefix(r.id, "service:") {
m.selection.Services = append(m.selection.Services, strings.TrimPrefix(r.id, "service:"))
}
}
}
}
func (m model) makePreview() tea.Cmd {
return func() tea.Msg {
file, err := os.CreateTemp("", "fiw-dots-selection-*.json")
if err != nil {
return previewMsg{err: err}
}
defer os.Remove(file.Name())
data, err := json.Marshal(m.selection)
if err == nil {
_, err = file.Write(data)
}
file.Close()
if err != nil {
return previewMsg{err: err}
}
cmd := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--plan", "--selection", file.Name())
output, err := cmd.CombinedOutput()
return previewMsg{string(output), err}
}
}
func (m model) loadBackups() tea.Cmd {
return func() tea.Msg {
output, err := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--list-backups").CombinedOutput()
var choices []backupChoice
if err == nil {
err = json.Unmarshal(output, &choices)
} else {
err = fmt.Errorf("%s", strings.TrimSpace(string(output)))
}
return backupsMsg{choices, err}
}
}
func (m model) backupPreview() tea.Cmd {
return func() tea.Msg {
output, err := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--backup-plan", m.backupID).CombinedOutput()
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 {
width = 80
}
if width < 10 {
width = 10
}
return strings.Split(ansi.Wrap(m.preview, width-4, ""), "\n")
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch v := msg.(type) {
case tea.WindowSizeMsg:
m.height = v.Height
m.width = v.Width
if m.scroll >= len(m.previewLines()) {
m.scroll = len(m.previewLines()) - 1
}
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()
}
case backupsMsg:
m.loading = false
m.rows = nil
if v.err != nil {
m.errorText = v.err.Error()
}
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" {
return m, tea.Quit
}
if m.loading {
return m, nil
}
if m.stage == 5 || m.stage == 7 || m.stage == 9 {
switch key {
case "up", "k":
if m.scroll > 0 {
m.scroll--
}
case "down", "j":
if m.scroll < len(m.previewLines())-1 {
m.scroll++
}
case "esc":
m.stage--
m.prepare()
if m.stage == 6 {
m.loading = true
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
m.prepare()
m.loading = true
m.errorText = ""
return m, m.loadBackups()
}
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" {
return m, nil
}
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
}
}
return m, nil
}
if m.stage == 6 || m.stage == 8 {
switch key {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
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
m.loading = true
return m, m.backupPreview()
}
case "esc":
m.stage = 5
m.scroll = 0
m.errorText = ""
m.loading = true
return m, m.makePreview()
}
return m, nil
}
switch key {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
case " ":
if len(m.rows) == 0 {
break
}
if m.stage == 0 {
if m.rows[m.cursor].id == "stock" {
m.selection = m.catalog.Stock
} else {
m.selection = m.catalog.Ryzen
}
m.prepare()
} else {
id := m.rows[m.cursor].id
if m.stage == 3 && (id == "binary" || id == "custom") {
for i := range m.rows {
if m.rows[i].id == "binary" || m.rows[i].id == "custom" {
m.rows[i].checked = false
}
}
m.rows[m.cursor].checked = true
} else if m.stage == 3 && (id == "keep" || id == "limine" || id == "grub") {
for i := range m.rows {
if m.rows[i].id == "keep" || m.rows[i].id == "limine" || m.rows[i].id == "grub" {
m.rows[i].checked = false
}
}
m.rows[m.cursor].checked = true
} else {
m.rows[m.cursor].checked = !m.rows[m.cursor].checked
}
}
case "enter":
if m.stage == 0 {
if m.rows[m.cursor].id == "stock" {
m.selection = m.catalog.Stock
} else {
m.selection = m.catalog.Ryzen
}
}
m.remember()
m.stage++
if m.stage == 5 {
m.loading = true
m.errorText = ""
return m, m.makePreview()
}
m.prepare()
case "esc":
if m.stage > 0 {
m.remember()
m.stage--
m.prepare()
}
}
}
return m, nil
}
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", "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 || m.stage == 9 {
if m.loading {
return text + " Preparing preview…\n"
}
lines := m.previewLines()
end := m.scroll + budget
if end > len(lines) {
end = len(lines)
}
for _, line := range lines[m.scroll:end] {
text += " " + line + "\n"
}
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 · 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.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 files…"
}
if m.errorText != "" {
message = m.errorText
}
return text + " " + message + "\n\n Esc back · q cancel\n"
}
start := 0
if m.cursor >= budget {
start = m.cursor - budget + 1
}
end := start + budget
if end > len(m.rows) {
end = len(m.rows)
}
for i := start; i < end; i++ {
r := m.rows[i]
mark := "[ ]"
if r.checked {
mark = "[✓]"
}
cursor := " "
if m.cursor == i {
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
}
text += line + "\n"
}
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"
}
func main() {
if len(os.Args) == 2 && os.Args[1] == "--version" {
fmt.Println("Fiw-Gentoo-Dots TUI " + tuiVersion)
return
}
repo, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output, err := exec.Command("python3", filepath.Join(repo, "lib", "rice.py"), "--catalog").Output()
if err != nil {
fmt.Fprintln(os.Stderr, "Cannot load the installer catalogue:", err)
os.Exit(1)
}
var c catalog
if err := json.Unmarshal(output, &c); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := model{repo: repo, catalog: c, selection: c.Stock, height: 28}
savedPath := filepath.Join(repo, "local", "selection.json")
if saved, readErr := os.ReadFile(savedPath); readErr == nil {
var previous selection
if json.Unmarshal(saved, &previous) == nil && (previous.Profile == "stock" || previous.Profile == "fiw-ryzen") {
m.selection = previous
m.stage = 1
}
}
m.prepare()
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
final := result.(model)
if !final.done {
return
}
path := filepath.Join(repo, "local", "selection.json")
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
data, _ := json.MarshalIndent(final.selection, "", " ")
if err := os.WriteFile(path, append(data, '\n'), 0600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(final.preview)
fmt.Println("\nSaved selection:", path)
runID := fmt.Sprintf("%s-%d", time.Now().UTC().Format("20060102T150405.000000000Z"), os.Getpid())
run := func(action string, root bool, extra ...string) error {
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, "--run-id", runID, action}
args = append(args, extra...)
program := "python3"
if root {
program = "sudo"
args = append([]string{"python3"}, args...)
}
cmd := exec.Command(program, args...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}
services := func() error {
system, user := false, false
for _, id := range final.selection.Services {
if c.Services[id].Scope == "system" {
system = true
}
if c.Services[id].Scope == "user" {
user = true
}
}
if system {
if err := run("--enable-services", true); err != nil {
return err
}
}
if user {
return run("--enable-services", false)
}
if !system {
fmt.Println("No services selected.")
}
return nil
}
var actionErr error
workflow := "restore"
switch final.action {
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)
case "f":
workflow = "flatpaks"
actionErr = run("--install-flatpaks", false)
case "v":
workflow = "services"
actionErr = services()
case "b":
workflow = "boot"
actionErr = run("--deploy-bootloader", true)
case "u":
workflow = "backup"
actionErr = run("--restore-backup", false, final.backupID)
case "r":
actionErr = run("--check-configs", false)
if actionErr == nil && (len(final.selection.Groups) > 0 || len(final.selection.Extras) > 0 || final.selection.Bootloader != "keep" || len(final.selection.Flatpaks) > 0 || len(final.selection.Services) > 0) {
actionErr = run("--install-packages", true)
}
if actionErr == nil && len(final.selection.Flatpaks) > 0 {
actionErr = run("--install-flatpaks", false)
}
if actionErr == nil && len(final.selection.Services) > 0 {
actionErr = services()
}
if actionErr == nil && len(final.selection.Configs) > 0 {
actionErr = run("--apply-configs", false)
}
if actionErr == nil && final.selection.Bootloader != "keep" {
actionErr = run("--deploy-bootloader", true)
}
default:
fmt.Println("Saved. Choose an action in the TUI or use the commands in docs/setup.md.")
return
}
summaryArgs := []string{"--workflow", workflow}
if actionErr != nil {
summaryArgs = append(summaryArgs, "--execution-error", actionErr.Error())
}
if summaryErr := run("--summary", false, summaryArgs...); summaryErr != nil {
fmt.Fprintln(os.Stderr, "Cannot create the combined report:", summaryErr)
}
if actionErr != nil {
var exitError *exec.ExitError
if errors.As(actionErr, &exitError) && exitError.ExitCode() == 130 {
fmt.Println("Cancelled. Completed steps remain recorded in the summary.")
os.Exit(130)
}
fmt.Fprintln(os.Stderr, actionErr)
os.Exit(1)
}
}