git/orchard/main
tui/main.go
107 lines3 KB last change raw · history
// Command updater-tui is a Bubble Tea front end over the updater Python backend.
//
// The split is deliberate: Go owns presentation, Python owns every fact. Nothing
// here reads a database, compares a version, or decides whether a package is
// safe to build. It renders what the backend says and reports what the user
// chooses, which keeps exactly one implementation of each piece of logic -- the
// one that is already verified against pacman.
package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"strconv"
	"strings"

	tea "github.com/charmbracelet/bubbletea"

	"updater-tui/internal/config"
	"updater-tui/internal/plan"
	"updater-tui/internal/ui"
)

func main() {
	var (
		python  = flag.String("python", "", "interpreter to run the backend (default $UPDATER_PYTHON or python3)")
		home    = flag.String("repo", "", "path to the updater tree, for an uninstalled checkout")
		offline = flag.Bool("offline", false, "skip everything that needs the network")
		noScan  = flag.Bool("fast", false, "skip the slow optional scans (arch-audit)")
		version = flag.Bool("version", false, "print the version and exit")
		print   = flag.String("print", "", "render one frame at WxH and exit, e.g. 120x36")
		show    = flag.String("show", "", "with --print, open an overlay: help, confirm, detail, advisory, security, plan, warnings or loading")
		pkg     = flag.String("pkg", "", "with --show detail, which package to open")
	)
	flag.Parse()

	if *version {
		fmt.Println("updater-tui 0.1.0")
		return
	}

	backend := plan.DefaultBackend()
	if *python != "" {
		backend.Python = *python
	}
	backend = backend.Discover(*home)
	if *offline {
		backend.Args = append(backend.Args, "--offline")
	}
	if *noScan {
		backend.Args = append(backend.Args, "--no-security")
	}

	model := ui.New(backend, config.Load())
	model.SetHint(backend.Hint())

	// A TUI cannot be inspected without a terminal, which makes it awkward to
	// test and impossible to screenshot. Rendering one frame to stdout is the
	// escape hatch: same code path, no alt screen.
	if *print != "" {
		w, h, err := parseSize(*print)
		if err != nil {
			fmt.Fprintf(os.Stderr, "updater-tui: %v\n", err)
			os.Exit(2)
		}
		frame, err := model.RenderOnce(w, h, *show, *pkg)
		if errors.Is(err, ui.ErrNothingToShow) {
			// said, not failed: there is no overlay to draw, which is an
			// answer rather than a fault
			fmt.Fprintf(os.Stderr, "updater-tui: %v\n", err)
			return
		}
		if err != nil {
			fmt.Fprintf(os.Stderr, "updater-tui: %v\n%s\n", err, backend.Hint())
			os.Exit(1)
		}
		fmt.Println(frame)
		return
	}

	opts := []tea.ProgramOption{tea.WithAltScreen()}
	if pager := os.Getenv("UPDATER_PAGER"); pager != "" {
		opts = append(opts, tea.WithoutSignalHandler())
	}
	if _, err := tea.NewProgram(model, opts...).Run(); err != nil {
		fmt.Fprintf(os.Stderr, "updater-tui: %v\n", err)
		os.Exit(1)
	}
}

func parseSize(spec string) (int, int, error) {
	parts := strings.SplitN(strings.ToLower(spec), "x", 2)
	if len(parts) != 2 {
		return 0, 0, fmt.Errorf("expected WxH, got %q", spec)
	}
	w, err := strconv.Atoi(strings.TrimSpace(parts[0]))
	if err != nil {
		return 0, 0, fmt.Errorf("bad width in %q", spec)
	}
	h, err := strconv.Atoi(strings.TrimSpace(parts[1]))
	if err != nil {
		return 0, 0, fmt.Errorf("bad height in %q", spec)
	}
	return w, h, nil
}