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}