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}