0.1.6 11bec3a

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/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}

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