1// the update command is used to update gordon's executable to the latest version
2
3// Heavily inspired by Pocketbase's update command
4// See: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
5package cmd
6
7// import (
8// "context"
9// "encoding/json"
10// "errors"
11// "fmt"
12// "io"
13// "log"
14// "net/http"
15// "os"
16// "path/filepath"
17// "runtime"
18// "strconv"
19// "strings"
20
21// "github.com/AlecAivazis/survey/v2"
22// "github.com/fatih/color"
23// "github.com/pocketbase/pocketbase/core"
24// "github.com/pocketbase/pocketbase/tools/archive"
25// "github.com/spf13/cobra"
26// )
27
28// // HttpClient is a base HTTP client interface (usually used for test purposes).
29// type HttpClient interface {
30// Do(req *http.Request) (*http.Response, error)
31// }
32
33// // Config defines the config options of the ghupdate plugin.
34// //
35// // NB! This plugin is considered experimental and its config options may change in the future.
36// type Config struct {
37// // Owner specifies the account owner of the repository (default to "pocketbase").
38// Owner string
39
40// // Repo specifies the name of the repository (default to "pocketbase").
41// Repo string
42
43// // ArchiveExecutable specifies the name of the executable file in the release archive
44// // (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
45// ArchiveExecutable string
46
47// // Optional context to use when fetching and downloading the latest release.
48// Context context.Context
49
50// // The HTTP client to use when fetching and downloading the latest release.
51// // Defaults to `http.DefaultClient`.
52// HttpClient HttpClient
53// }
54
55// // MustRegister registers the ghupdate plugin to the provided app instance
56// // and panic if it fails.
57// func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
58// if err := Register(app, rootCmd, config); err != nil {
59// panic(err)
60// }
61// }
62
63// // Register registers the ghupdate plugin to the provided app instance.
64// func Register(app core.App, rootCmd *cobra.Command, config Config) error {
65// p := &plugin{
66// app: app,
67// currentVersion: rootCmd.Version,
68// config: config,
69// }
70
71// if p.config.Owner == "" {
72// p.config.Owner = "pocketbase"
73// }
74
75// if p.config.Repo == "" {
76// p.config.Repo = "pocketbase"
77// }
78
79// if p.config.ArchiveExecutable == "" {
80// p.config.ArchiveExecutable = "pocketbase"
81// }
82
83// if p.config.HttpClient == nil {
84// p.config.HttpClient = http.DefaultClient
85// }
86
87// if p.config.Context == nil {
88// p.config.Context = context.Background()
89// }
90
91// rootCmd.AddCommand(p.updateCmd())
92
93// return nil
94// }
95
96// type plugin struct {
97// app core.App
98// currentVersion string
99// config Config
100// }
101
102// func (p *plugin) updateCmd() *cobra.Command {
103// var withBackup bool
104
105// command := &cobra.Command{
106// Use: "update",
107// Short: "Automatically updates the current PocketBase executable with the latest available version",
108// // @todo remove after logs generalization
109// // prevents printing the error log twice
110// SilenceErrors: true,
111// SilenceUsage: true,
112// RunE: func(command *cobra.Command, args []string) error {
113// var needConfirm bool
114// if isMaybeRunningInDocker() {
115// needConfirm = true
116// color.Yellow("NB! It seems that you are in a Docker container.")
117// 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.")
118// } else if isMaybeRunningInNixOS() {
119// needConfirm = true
120// color.Yellow("NB! It seems that you are in a NixOS.")
121// color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
122// }
123
124// if needConfirm {
125// confirm := false
126// prompt := &survey.Confirm{
127// Message: "Do you want to proceed with the update?",
128// }
129// survey.AskOne(prompt, &confirm)
130// if !confirm {
131// fmt.Println("The command has been cancelled.")
132// return nil
133// }
134// }
135
136// return p.update(withBackup)
137// },
138// }
139
140// command.PersistentFlags().BoolVar(
141// &withBackup,
142// "backup",
143// true,
144// "Creates a pb_data backup at the end of the update process",
145// )
146
147// return command
148// }
149
150// func (p *plugin) update(withBackup bool) error {
151// color.Yellow("Fetching release information...")
152
153// latest, err := fetchLatestRelease(
154// p.config.Context,
155// p.config.HttpClient,
156// p.config.Owner,
157// p.config.Repo,
158// )
159// if err != nil {
160// return err
161// }
162
163// if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
164// color.Green("You already have the latest PocketBase %s.", p.currentVersion)
165// return nil
166// }
167
168// suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
169// if suffix == "" {
170// return errors.New("unsupported platform")
171// }
172
173// asset, err := latest.findAssetBySuffix(suffix)
174// if err != nil {
175// return err
176// }
177
178// releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
179// defer os.RemoveAll(releaseDir)
180
181// color.Yellow("Downloading %s...", asset.Name)
182
183// // download the release asset
184// assetZip := filepath.Join(releaseDir, asset.Name)
185// if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
186// return err
187// }
188
189// color.Yellow("Extracting %s...", asset.Name)
190
191// extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
192// defer os.RemoveAll(extractDir)
193
194// if err := archive.Extract(assetZip, extractDir); err != nil {
195// return err
196// }
197
198// color.Yellow("Replacing the executable...")
199
200// oldExec, err := os.Executable()
201// if err != nil {
202// return err
203// }
204// renamedOldExec := oldExec + ".old"
205// defer os.Remove(renamedOldExec)
206
207// newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
208// if _, err := os.Stat(newExec); err != nil {
209// // try again with an .exe extension
210// newExec = newExec + ".exe"
211// if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
212// return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
213// }
214// }
215
216// // rename the current executable
217// if err := os.Rename(oldExec, renamedOldExec); err != nil {
218// return fmt.Errorf("Failed to rename the current executable: %w", err)
219// }
220
221// tryToRevertExecChanges := func() {
222// if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
223// log.Println(revertErr)
224// }
225// }
226
227// // replace with the extracted binary
228// if err := os.Rename(newExec, oldExec); err != nil {
229// tryToRevertExecChanges()
230// return fmt.Errorf("Failed replacing the executable: %w", err)
231// }
232
233// if withBackup {
234// color.Yellow("Creating pb_data backup...")
235
236// backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
237// if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
238// tryToRevertExecChanges()
239// return err
240// }
241// }
242
243// color.HiBlack("---")
244// color.Green("Update completed successfully! You can start the executable as usual.")
245
246// return nil
247// }
248
249// func fetchLatestRelease(
250// ctx context.Context,
251// client HttpClient,
252// owner string,
253// repo string,
254// ) (*release, error) {
255// url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
256
257// req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
258// if err != nil {
259// return nil, err
260// }
261
262// res, err := client.Do(req)
263// if err != nil {
264// return nil, err
265// }
266// defer res.Body.Close()
267
268// rawBody, err := io.ReadAll(res.Body)
269// if err != nil {
270// return nil, err
271// }
272
273// // http.Client doesn't treat non 2xx responses as error
274// if res.StatusCode >= 400 {
275// return nil, fmt.Errorf(
276// "(%d) failed to fetch latest releases:\n%s",
277// res.StatusCode,
278// string(rawBody),
279// )
280// }
281
282// result := &release{}
283// if err := json.Unmarshal(rawBody, result); err != nil {
284// return nil, err
285// }
286
287// return result, nil
288// }
289
290// func downloadFile(
291// ctx context.Context,
292// client HttpClient,
293// url string,
294// destPath string,
295// ) error {
296// req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
297// if err != nil {
298// return err
299// }
300
301// res, err := client.Do(req)
302// if err != nil {
303// return err
304// }
305// defer res.Body.Close()
306
307// // http.Client doesn't treat non 2xx responses as error
308// if res.StatusCode >= 400 {
309// return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
310// }
311
312// // ensure that the dest parent dir(s) exist
313// if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
314// return err
315// }
316
317// dest, err := os.Create(destPath)
318// if err != nil {
319// return err
320// }
321// defer dest.Close()
322
323// if _, err := io.Copy(dest, res.Body); err != nil {
324// return err
325// }
326
327// return nil
328// }
329
330// func archiveSuffix(goos, goarch string) string {
331// switch goos {
332// case "linux":
333// switch goarch {
334// case "amd64":
335// return "_linux_amd64.zip"
336// case "arm64":
337// return "_linux_arm64.zip"
338// case "arm":
339// return "_linux_armv7.zip"
340// }
341// case "darwin":
342// switch goarch {
343// case "amd64":
344// return "_darwin_amd64.zip"
345// case "arm64":
346// return "_darwin_arm64.zip"
347// }
348// case "windows":
349// switch goarch {
350// case "amd64":
351// return "_windows_amd64.zip"
352// case "arm64":
353// return "_windows_arm64.zip"
354// }
355// }
356
357// return ""
358// }
359
360// func compareVersions(a, b string) int {
361// aSplit := strings.Split(a, ".")
362// aTotal := len(aSplit)
363
364// bSplit := strings.Split(b, ".")
365// bTotal := len(bSplit)
366
367// limit := aTotal
368// if bTotal > aTotal {
369// limit = bTotal
370// }
371
372// for i := 0; i < limit; i++ {
373// var x, y int
374
375// if i < aTotal {
376// x, _ = strconv.Atoi(aSplit[i])
377// }
378
379// if i < bTotal {
380// y, _ = strconv.Atoi(bSplit[i])
381// }
382
383// if x < y {
384// return 1 // b is newer
385// }
386
387// if x > y {
388// return -1 // a is newer
389// }
390// }
391
392// return 0 // equal
393// }
394
395// // note: not completely reliable as it may not work on all platforms
396// // but should at least provide a warning for the most common use cases
397// func isMaybeRunningInDocker() bool {
398// _, err := os.Stat("/.dockerenv")
399// return err == nil
400// }
401
402// // note: untested
403// func isMaybeRunningInNixOS() bool {
404// _, err := os.Stat("/etc/NIXOS")
405// return err == nil
406// }