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 return nil
141}
142
143func getLatestVersion(modulePath string) (string, error) {
144 cmd := exec.Command("go", "list", "-m", "-json", modulePath+"@latest")
145 output, err := cmd.Output()
146 if err != nil {
147 return "", err
148 }
149
150 var info moduleInfo
151 if err := json.Unmarshal(output, &info); err != nil {
152 return "", err
153 }
154
155 return info.Version, nil
156}
157
158func performUpgrade(modulePath, version string) error {
159 if !term.IsTerminal(int(os.Stdout.Fd())) {
160 fmt.Println("Installing…")
161 return runGoInstall(modulePath)
162 }
163
164 m := spinnerModel{
165 spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
166 text: fmt.Sprintf("Installing %s…", chipStyle.Render(version)),
167 run: func() error { return runGoInstall(modulePath) },
168 }
169
170 if _, err := tea.NewProgram(m).Run(); err != nil {
171 return err
172 }
173 return m.err
174}
175
176func runGoInstall(modulePath string) error {
177 cmd := exec.Command("go", "install", modulePath+"@latest")
178 var stderr bytes.Buffer
179 cmd.Stdout = io.Discard
180 cmd.Stderr = &stderr
181
182 if err := cmd.Run(); err != nil {
183 return fmt.Errorf("%w\n%s", err, strings.TrimSpace(stderr.String()))
184 }
185 return nil
186}
187
188type installMsg struct{ err error }
189
190type spinnerModel struct {
191 spinner spinner.Model
192 text string
193 err error
194 run func() error
195 done bool
196}
197
198func (m spinnerModel) Init() tea.Cmd {
199 return tea.Batch(m.spinner.Tick, func() tea.Msg {
200 return installMsg{err: m.run()}
201 })
202}
203
204func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
205 switch msg := msg.(type) {
206 case spinner.TickMsg:
207 var cmd tea.Cmd
208 m.spinner, cmd = m.spinner.Update(msg)
209 return m, cmd
210 case installMsg:
211 m.err = msg.err
212 m.done = true
213 return m, tea.Quit
214 default:
215 return m, nil
216 }
217}
218
219func (m spinnerModel) View() string {
220 if m.done {
221 return ""
222 }
223 return fmt.Sprintf("%s %s", m.spinner.View(), m.text)
224}