0.1 5d00907

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

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