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