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	check = "✓"
 46	cross = "✗"
 47	arrow = "→"
 48)
 49
 50var upgradeCmd = &cobra.Command{
 51	Use:   "upgrade",
 52	Short: "Check for and apply updates",
 53	RunE: func(cmd *cobra.Command, args []string) error {
 54		apply, _ := cmd.Flags().GetBool("apply")
 55		return runUpgrade(apply)
 56	},
 57}
 58
 59func init() {
 60	upgradeCmd.Flags().BoolP("apply", "a", false, "apply the upgrade")
 61	rootCmd.AddCommand(upgradeCmd)
 62}
 63
 64func runUpgrade(apply bool) error {
 65	info, ok := debug.ReadBuildInfo()
 66	if !ok {
 67		return fmt.Errorf("unable to read build info")
 68	}
 69
 70	if !strings.HasPrefix(info.Main.Path, "git.secluded.site/") {
 71		return fmt.Errorf("this binary was not installed from git.secluded.site, cannot upgrade")
 72	}
 73
 74	currentVersion := info.Main.Version
 75	isDevel := currentVersion == "" || currentVersion == "(devel)"
 76
 77	fmt.Println("Checking for updates…")
 78
 79	latestVersion, err := getLatestVersion(info.Main.Path)
 80	if err != nil {
 81		return fmt.Errorf("failed to check for updates: %w", err)
 82	}
 83
 84	if !isDevel {
 85		cmp := semver.Compare(currentVersion, latestVersion)
 86		if cmp == 0 {
 87			if apply {
 88				return fmt.Errorf("already up-to-date")
 89			}
 90			fmt.Printf("%s %s\n", successStyle.Render(check), "Already up to date")
 91			return nil
 92		} else if cmp > 0 {
 93			fmt.Printf("%s %s %s (remote %s)\n",
 94				warnStyle.Render("!"),
 95				"Local version is newer",
 96				chipStyle.Render(currentVersion),
 97				latestVersion)
 98			return nil
 99		}
100	}
101
102	if isDevel {
103		fmt.Println(warnStyle.Render("Dev build detected."))
104		fmt.Printf("Latest: %s\n", chipStyle.Render(latestVersion))
105	} else {
106		fmt.Printf("Update available: %s %s %s\n",
107			chipStyle.Render(currentVersion),
108			arrow,
109			chipStyle.Render(latestVersion))
110	}
111
112	if !apply {
113		fmt.Println("\nRun with --apply to upgrade")
114		return nil
115	}
116
117	var confirm bool
118	prompt := huh.NewConfirm().
119		Title(fmt.Sprintf("Upgrade to %s?", latestVersion)).
120		Value(&confirm)
121
122	if err := prompt.Run(); err != nil {
123		return err
124	}
125
126	if !confirm {
127		fmt.Println("Upgrade cancelled")
128		return nil
129	}
130
131	if err := performUpgrade(info.Main.Path, latestVersion); err != nil {
132		fmt.Printf("%s %s\n", errorStyle.Render(cross), "Upgrade failed")
133		return err
134	}
135
136	fmt.Printf("%s %s %s\n",
137		successStyle.Render(check),
138		"Upgraded to",
139		chipStyle.Render(latestVersion))
140	fmt.Printf("%s %s\n",
141		chipStyle.Render("NOTE:"),
142		"taking advantage of this binary upgrade might require updating your rules")
143	fmt.Printf("⮑ %s\n", lipgloss.NewStyle().Underline(true).Render("https://"+info.Main.Path))
144	return nil
145}
146
147func getLatestVersion(modulePath string) (string, error) {
148	cmd := exec.Command("go", "list", "-m", "-json", modulePath+"@latest")
149	output, err := cmd.Output()
150	if err != nil {
151		return "", err
152	}
153
154	var info moduleInfo
155	if err := json.Unmarshal(output, &info); err != nil {
156		return "", err
157	}
158
159	return info.Version, nil
160}
161
162func performUpgrade(modulePath, version string) error {
163	if !term.IsTerminal(int(os.Stdout.Fd())) {
164		fmt.Println("Installing…")
165		return runGoInstall(modulePath)
166	}
167
168	m := spinnerModel{
169		spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
170		text:    fmt.Sprintf("Installing %s…", chipStyle.Render(version)),
171		run:     func() error { return runGoInstall(modulePath) },
172	}
173
174	if _, err := tea.NewProgram(m).Run(); err != nil {
175		return err
176	}
177	return m.err
178}
179
180func runGoInstall(modulePath string) error {
181	cmd := exec.Command("go", "install", modulePath+"@latest")
182	var stderr bytes.Buffer
183	cmd.Stdout = io.Discard
184	cmd.Stderr = &stderr
185
186	if err := cmd.Run(); err != nil {
187		return fmt.Errorf("%w\n%s", err, strings.TrimSpace(stderr.String()))
188	}
189	return nil
190}
191
192type installMsg struct{ err error }
193
194type spinnerModel struct {
195	spinner spinner.Model
196	text    string
197	err     error
198	run     func() error
199	done    bool
200}
201
202func (m spinnerModel) Init() tea.Cmd {
203	return tea.Batch(m.spinner.Tick, func() tea.Msg {
204		return installMsg{err: m.run()}
205	})
206}
207
208func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
209	switch msg := msg.(type) {
210	case spinner.TickMsg:
211		var cmd tea.Cmd
212		m.spinner, cmd = m.spinner.Update(msg)
213		return m, cmd
214	case installMsg:
215		m.err = msg.err
216		m.done = true
217		return m, tea.Quit
218	default:
219		return m, nil
220	}
221}
222
223func (m spinnerModel) View() string {
224	if m.done {
225		return ""
226	}
227	return fmt.Sprintf("%s %s", m.spinner.View(), m.text)
228}