upgrade.go

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