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}