upgrade.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package main
  6
  7import (
  8	"bytes"
  9	"encoding/json"
 10	"fmt"
 11	"io"
 12	"os"
 13	"os/exec"
 14	"runtime/debug"
 15	"strings"
 16
 17	"github.com/charmbracelet/bubbles/spinner"
 18	tea "github.com/charmbracelet/bubbletea"
 19	"github.com/charmbracelet/huh"
 20	"github.com/charmbracelet/lipgloss"
 21	"github.com/spf13/cobra"
 22	"golang.org/x/mod/semver"
 23	"golang.org/x/term"
 24)
 25
 26type moduleInfo struct {
 27	Version string `json:"Version"`
 28	Time    string `json:"Time"`
 29}
 30
 31var (
 32	successStyle = lipgloss.NewStyle().Foreground(lipgloss.CompleteAdaptiveColor{
 33		Light: lipgloss.CompleteColor{TrueColor: "#00AA00", ANSI256: "34", ANSI: "2"},
 34		Dark:  lipgloss.CompleteColor{TrueColor: "#00FF00", ANSI256: "46", ANSI: "10"},
 35	}).Bold(true)
 36	warnStyle = lipgloss.NewStyle().Foreground(lipgloss.CompleteAdaptiveColor{
 37		Light: lipgloss.CompleteColor{TrueColor: "#AA5500", ANSI256: "166", ANSI: "3"},
 38		Dark:  lipgloss.CompleteColor{TrueColor: "#FFAA00", ANSI256: "214", ANSI: "11"},
 39	}).Bold(true)
 40	errorStyle = lipgloss.NewStyle().Foreground(lipgloss.CompleteAdaptiveColor{
 41		Light: lipgloss.CompleteColor{TrueColor: "#AA0000", ANSI256: "124", ANSI: "1"},
 42		Dark:  lipgloss.CompleteColor{TrueColor: "#FF5555", ANSI256: "203", ANSI: "9"},
 43	}).Bold(true)
 44	chipStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).
 45			Background(lipgloss.CompleteAdaptiveColor{
 46			Light: lipgloss.CompleteColor{TrueColor: "#F2F2FF", ANSI256: "195", ANSI: "7"},
 47			Dark:  lipgloss.CompleteColor{TrueColor: "#5A4A8A", ANSI256: "98", ANSI: "5"},
 48		})
 49	cmdStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).
 50			Background(lipgloss.CompleteAdaptiveColor{
 51			Light: lipgloss.CompleteColor{TrueColor: "#EDEDED", ANSI256: "254", ANSI: "7"},
 52			Dark:  lipgloss.CompleteColor{TrueColor: "#444444", ANSI256: "238", ANSI: "8"},
 53		})
 54	check = "✓"
 55	cross = "✗"
 56	arrow = "→"
 57)
 58
 59var upgradeCmd = &cobra.Command{
 60	Use:   "upgrade",
 61	Short: "Check for and apply updates",
 62	RunE: func(cmd *cobra.Command, args []string) error {
 63		apply, _ := cmd.Flags().GetBool("apply")
 64		return runUpgrade(apply)
 65	},
 66}
 67
 68func init() {
 69	upgradeCmd.Flags().BoolP("apply", "a", false, "apply the upgrade")
 70	rootCmd.AddCommand(upgradeCmd)
 71}
 72
 73func runUpgrade(apply bool) error {
 74	info, ok := debug.ReadBuildInfo()
 75	if !ok {
 76		return fmt.Errorf("unable to read build info")
 77	}
 78
 79	if !strings.HasPrefix(info.Main.Path, "git.secluded.site/") {
 80		return fmt.Errorf("this binary was not installed from git.secluded.site, cannot upgrade")
 81	}
 82
 83	currentVersion := info.Main.Version
 84	isDevel := currentVersion == "" || currentVersion == "(devel)"
 85
 86	fmt.Println("Checking for updates…")
 87
 88	latestVersion, err := getLatestVersion(info.Main.Path)
 89	if err != nil {
 90		return fmt.Errorf("failed to check for updates: %w", err)
 91	}
 92
 93	if !isDevel {
 94		cmp := semver.Compare(currentVersion, latestVersion)
 95		if cmp == 0 {
 96			if apply {
 97				return fmt.Errorf("already up-to-date")
 98			}
 99			fmt.Printf("%s %s\n", successStyle.Render(check), "Already up to date")
100			return nil
101		} else if cmp > 0 {
102			fmt.Printf("%s %s %s (remote %s)\n",
103				warnStyle.Render("!"),
104				"Local version is newer",
105				chipStyle.Render(currentVersion),
106				latestVersion)
107			return nil
108		}
109	}
110
111	if isDevel {
112		fmt.Println(warnStyle.Render("Dev build detected."))
113		fmt.Printf("Latest: %s\n", chipStyle.Render(latestVersion))
114	} else {
115		fmt.Printf("Update available: %s %s %s\n",
116			chipStyle.Render(currentVersion),
117			arrow,
118			chipStyle.Render(latestVersion))
119	}
120
121	if !apply {
122		fmt.Printf("\nRun %s to apply the upgrade\n", cmdStyle.Render("formatted-commit upgrade -a"))
123		return nil
124	}
125
126	var confirm bool
127	prompt := huh.NewConfirm().
128		Title(fmt.Sprintf("Upgrade to %s?", latestVersion)).
129		Value(&confirm)
130
131	if err := prompt.Run(); err != nil {
132		return err
133	}
134
135	if !confirm {
136		fmt.Println("Upgrade cancelled")
137		return nil
138	}
139
140	if err := performUpgrade(info.Main.Path, latestVersion); err != nil {
141		fmt.Printf("%s %s\n", errorStyle.Render(cross), "Upgrade failed")
142		return err
143	}
144
145	fmt.Printf("%s %s %s\n",
146		successStyle.Render(check),
147		"Upgraded to",
148		chipStyle.Render(latestVersion))
149	fmt.Printf("%s %s\n",
150		chipStyle.Render("NOTE:"),
151		"taking advantage of this binary upgrade might require updating your rules")
152	fmt.Printf("⮑ %s\n", lipgloss.NewStyle().Underline(true).Render("https://"+info.Main.Path))
153	return nil
154}
155
156func getLatestVersion(modulePath string) (string, error) {
157	cmd := exec.Command("go", "list", "-m", "-json", modulePath+"@latest")
158	output, err := cmd.Output()
159	if err != nil {
160		return "", err
161	}
162
163	var info moduleInfo
164	if err := json.Unmarshal(output, &info); err != nil {
165		return "", err
166	}
167
168	return info.Version, nil
169}
170
171func performUpgrade(modulePath, version string) error {
172	if !term.IsTerminal(int(os.Stdout.Fd())) {
173		fmt.Println("Installing…")
174		return runGoInstall(modulePath)
175	}
176
177	m := spinnerModel{
178		spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
179		text:    fmt.Sprintf("Installing %s…", chipStyle.Render(version)),
180		run:     func() error { return runGoInstall(modulePath) },
181	}
182
183	if _, err := tea.NewProgram(m).Run(); err != nil {
184		return err
185	}
186	return m.err
187}
188
189func runGoInstall(modulePath string) error {
190	cmd := exec.Command("go", "install", modulePath+"@latest")
191	var stderr bytes.Buffer
192	cmd.Stdout = io.Discard
193	cmd.Stderr = &stderr
194
195	if err := cmd.Run(); err != nil {
196		return fmt.Errorf("%w\n%s", err, strings.TrimSpace(stderr.String()))
197	}
198	return nil
199}
200
201type installMsg struct{ err error }
202
203type spinnerModel struct {
204	spinner spinner.Model
205	text    string
206	err     error
207	run     func() error
208	done    bool
209}
210
211func (m spinnerModel) Init() tea.Cmd {
212	return tea.Batch(m.spinner.Tick, func() tea.Msg {
213		return installMsg{err: m.run()}
214	})
215}
216
217func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
218	switch msg := msg.(type) {
219	case spinner.TickMsg:
220		var cmd tea.Cmd
221		m.spinner, cmd = m.spinner.Update(msg)
222		return m, cmd
223	case installMsg:
224		m.err = msg.err
225		m.done = true
226		return m, tea.Quit
227	default:
228		return m, nil
229	}
230}
231
232func (m spinnerModel) View() string {
233	if m.done {
234		return ""
235	}
236	return fmt.Sprintf("%s %s", m.spinner.View(), m.text)
237}