0.1.9 85724f1

update.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close