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