1// This upload feature is based on pocketbase's ghupdate command.
2// Thank you for your work, @Gani Georgiev
3// Check the original code here: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
4package cmd
5
6import (
7 "context"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "log"
13 "net/http"
14 "os"
15 "path/filepath"
16 "runtime"
17 "strconv"
18 "strings"
19
20 "github.com/AlecAivazis/survey/v2"
21 "github.com/bnema/gordon/internal/cli"
22 "github.com/bnema/gordon/pkg/docker"
23 "github.com/fatih/color"
24 "github.com/pocketbase/pocketbase/tools/archive"
25 "github.com/spf13/cobra"
26)
27
28type HttpClient interface {
29 Do(req *http.Request) (*http.Response, error)
30}
31
32type Config struct {
33 Owner string
34 Repo string
35 ArchiveExecutable string
36 Context context.Context
37 HttpClient HttpClient
38}
39
40var pluginInstance *plugin
41
42type plugin struct {
43 app *cli.App
44 currentVersion string
45 config Config
46}
47
48func NewUpdateCommand(a *cli.App) *cobra.Command {
49
50 pluginInstance = &plugin{
51 app: a,
52 currentVersion: a.Config.Build.BuildVersion,
53 config: Config{
54 Owner: "bnema",
55 Repo: "gordon",
56 ArchiveExecutable: "gordon",
57 Context: context.Background(),
58 HttpClient: http.DefaultClient,
59 },
60 }
61
62 if pluginInstance.app != a {
63 panic("app instance is not the same")
64 }
65
66 if a == nil {
67 panic("app instance is nil")
68 }
69
70 command := &cobra.Command{
71 Use: "update",
72 Short: "Update the Gordon executable",
73 SilenceErrors: true,
74 SilenceUsage: true,
75 RunE: func(command *cobra.Command, args []string) error {
76 var needConfirm bool
77 if docker.IsRunningInContainer() {
78 needConfirm = true
79 color.Yellow("NB! It seems that you are in a Docker container.")
80 color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.")
81 }
82
83 if needConfirm {
84 confirm := false
85 prompt := &survey.Confirm{
86 Message: "Do you want to proceed with the update?",
87 }
88 survey.AskOne(prompt, &confirm)
89 if !confirm {
90 fmt.Println("The command has been cancelled.")
91 return nil
92 }
93 }
94
95 return pluginInstance.update()
96 },
97 }
98 return command
99}
100
101func (p *plugin) update() error {
102 color.Yellow("Fetching release information...")
103
104 latest, err := fetchLatestRelease(
105 p.config.Context,
106 p.config.HttpClient,
107 p.config.Owner,
108 p.config.Repo,
109 )
110 if err != nil {
111 return err
112 }
113
114 if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
115 color.Green("You already have the latest version %s.", p.currentVersion)
116 return nil
117 }
118
119 suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
120 if suffix == "" {
121 return errors.New("unsupported platform")
122 }
123
124 asset, err := latest.findAssetBySuffix(suffix)
125 if err != nil {
126 return err
127 }
128
129 // temporary file is under common.Config.General.StorageDir
130 releaseDir := filepath.Join(p.app.Config.General.StorageDir, "update")
131 defer os.RemoveAll(releaseDir)
132
133 color.Yellow("Downloading %s...", asset.Name)
134
135 // download the release asset
136 assetZip := filepath.Join(releaseDir, asset.Name)
137 if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
138 return err
139 }
140
141 color.Yellow("Extracting %s...", asset.Name)
142
143 extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
144 defer os.RemoveAll(extractDir)
145
146 if err := archive.Extract(assetZip, extractDir); err != nil {
147 return err
148 }
149
150 color.Yellow("Replacing the executable...")
151
152 oldExec, err := os.Executable()
153 if err != nil {
154 return err
155 }
156 renamedOldExec := oldExec + ".old"
157 defer os.Remove(renamedOldExec)
158
159 newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
160 if _, err := os.Stat(newExec); err != nil {
161 // try again with an .exe extension
162 newExec = newExec + ".exe"
163 if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
164 return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
165 }
166 }
167
168 // rename the current executable
169 if err := os.Rename(oldExec, renamedOldExec); err != nil {
170 return fmt.Errorf("Failed to rename the current executable: %w", err)
171 }
172
173 tryToRevertExecChanges := func() {
174 if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
175 log.Println(revertErr)
176 }
177 }
178
179 // replace with the extracted binary
180 if err := os.Rename(newExec, oldExec); err != nil {
181 tryToRevertExecChanges()
182 return fmt.Errorf("Failed replacing the executable: %w", err)
183 }
184
185 color.HiBlack("---")
186 color.Green("Update completed successfully! You can start the executable as usual.")
187
188 return nil
189}
190
191func (r *release) findAssetBySuffix(suffix string) (*asset, error) {
192 for _, asset := range r.Assets {
193 if strings.HasSuffix(asset.Name, suffix) {
194 return &asset, nil
195 }
196 }
197
198 return nil, fmt.Errorf("no asset found with suffix %q", suffix)
199}
200
201type release struct {
202 Tag string `json:"tag_name"`
203 Assets []asset `json:"assets"`
204}
205
206type asset struct {
207 Name string `json:"name"`
208 DownloadUrl string `json:"browser_download_url"`
209}
210
211func fetchLatestRelease(
212 ctx context.Context,
213 client HttpClient,
214 owner string,
215 repo string,
216) (*release, error) {
217 url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
218
219 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
220 if err != nil {
221 return nil, err
222 }
223
224 res, err := client.Do(req)
225 if err != nil {
226 return nil, err
227 }
228 defer res.Body.Close()
229
230 rawBody, err := io.ReadAll(res.Body)
231 if err != nil {
232 return nil, err
233 }
234
235 // http.Client doesn't treat non 2xx responses as error
236 if res.StatusCode >= 400 {
237 return nil, fmt.Errorf(
238 "(%d) failed to fetch latest releases:\n%s",
239 res.StatusCode,
240 string(rawBody),
241 )
242 }
243
244 result := &release{}
245 if err := json.Unmarshal(rawBody, result); err != nil {
246 return nil, err
247 }
248
249 return result, nil
250}
251
252func downloadFile(
253 ctx context.Context,
254 client HttpClient,
255 url string,
256 destPath string,
257) error {
258 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
259 if err != nil {
260 return err
261 }
262
263 res, err := client.Do(req)
264 if err != nil {
265 return err
266 }
267 defer res.Body.Close()
268
269 // http.Client doesn't treat non 2xx responses as error
270 if res.StatusCode >= 400 {
271 return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
272 }
273
274 // ensure that the dest parent dir(s) exist
275 if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
276 return err
277 }
278
279 dest, err := os.Create(destPath)
280 if err != nil {
281 return err
282 }
283 defer dest.Close()
284
285 if _, err := io.Copy(dest, res.Body); err != nil {
286 return err
287 }
288
289 return nil
290}
291
292// archives:
293// - format: zip
294// name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
295
296func archiveSuffix(goos, goarch string) string {
297 // Define a map to hold the combinations of goos and goarch.
298 var archMap = map[string]map[string]string{
299 "linux": {
300 "amd64": "_linux_amd64.zip",
301 "arm64": "_linux_arm64.zip",
302 "arm": "_linux_armv7.zip",
303 },
304 "darwin": {
305 "amd64": "_darwin_amd64.zip",
306 "arm64": "_darwin_arm64.zip",
307 },
308 "windows": {
309 "amd64": "_windows_amd64.zip",
310 "arm64": "_windows_arm64.zip",
311 },
312 }
313
314 // Look up goos then goarch.
315 if arch, ok := archMap[goos]; ok {
316 if suffix, ok := arch[goarch]; ok {
317 return suffix
318 }
319 }
320
321 // Return a default value if no match is found.
322 return ""
323}
324func compareVersions(a, b string) int {
325 aSplit := strings.Split(a, ".")
326 aTotal := len(aSplit)
327
328 bSplit := strings.Split(b, ".")
329 bTotal := len(bSplit)
330
331 limit := aTotal
332 if bTotal > aTotal {
333 limit = bTotal
334 }
335
336 for i := 0; i < limit; i++ {
337 var x, y int
338
339 if i < aTotal {
340 x, _ = strconv.Atoi(aSplit[i])
341 }
342
343 if i < bTotal {
344 y, _ = strconv.Atoi(bSplit[i])
345 }
346
347 if x < y {
348 return 1 // b is newer
349 }
350
351 if x > y {
352 return -1 // a is newer
353 }
354 }
355
356 return 0 // equal
357}