detached 9a610bb

Add basic bubbletea example

commit
9a610bba6d6dfd539faa46b1fc4fbf6c49097365
author
bnema · · 2023-10-21 14:34 UTC
parent
ebb6bfa
signature
unsigned
11 files · +597 -431
M cmd/cli/main.go +4 -2
@@ -1,8 +1,10 @@
11 package main
22
3-import "fmt"
3+import (
4+ "github.com/bnema/gordon/internal/cli"
5+)
46
57 func main() {
6- fmt.Println("Hello World")
8+ cli.InitCli()
79
810 }
@@ -1,8 +1,10 @@
1 package main1 package main
2 2
3-import "fmt"3+import (
4+ "github.com/bnema/gordon/internal/cli"
5+)
4 6
5 func main() {7 func main() {
6- fmt.Println("Hello World")8+ cli.InitCli()
7 9
8 }10 }
M go.mod +24 -8
@@ -4,6 +4,7 @@ go 1.21.3
44
55 require (
66 github.com/PuerkitoBio/goquery v1.8.1
7+ github.com/charmbracelet/bubbletea v0.24.2
78 github.com/docker/docker v24.0.6+incompatible
89 github.com/docker/go-connections v0.4.0
910 github.com/gorilla/sessions v1.2.1
@@ -15,6 +16,23 @@ require (
1516 gopkg.in/yaml.v3 v3.0.1
1617 )
1718
19+require (
20+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
21+ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
22+ github.com/kr/text v0.2.0 // indirect
23+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
24+ github.com/mattn/go-localereader v0.0.1 // indirect
25+ github.com/mattn/go-runewidth v0.0.14 // indirect
26+ github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
27+ github.com/muesli/cancelreader v0.2.2 // indirect
28+ github.com/muesli/reflow v0.3.0 // indirect
29+ github.com/muesli/termenv v0.15.1 // indirect
30+ github.com/rivo/uniseg v0.2.0 // indirect
31+ github.com/stretchr/testify v1.8.2 // indirect
32+ golang.org/x/sync v0.4.0 // indirect
33+ golang.org/x/term v0.13.0 // indirect
34+)
35+
1836 require (
1937 github.com/Microsoft/go-winio v0.6.1 // indirect
2038 github.com/andybalholm/cascadia v1.3.1 // indirect
@@ -27,7 +45,6 @@ require (
2745 github.com/gorilla/context v1.1.1 // indirect
2846 github.com/gorilla/css v1.0.0 // indirect
2947 github.com/gorilla/securecookie v1.1.1 // indirect
30- github.com/kr/text v0.1.0 // indirect
3148 github.com/labstack/gommon v0.4.0 // indirect
3249 github.com/mattn/go-colorable v0.1.13 // indirect
3350 github.com/mattn/go-isatty v0.0.19 // indirect
@@ -37,16 +54,15 @@ require (
3754 github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect
3855 github.com/pkg/errors v0.9.1 // indirect
3956 github.com/rogpeppe/go-internal v1.11.0 // indirect
40- github.com/stretchr/testify v1.8.2 // indirect
4157 github.com/valyala/bytebufferpool v1.0.0 // indirect
4258 github.com/valyala/fasttemplate v1.2.2 // indirect
43- golang.org/x/crypto v0.11.0 // indirect
44- golang.org/x/mod v0.9.0 // indirect
45- golang.org/x/net v0.12.0 // indirect
46- golang.org/x/sys v0.10.0 // indirect
47- golang.org/x/text v0.11.0 // indirect
59+ golang.org/x/crypto v0.14.0 // indirect
60+ golang.org/x/mod v0.13.0 // indirect
61+ golang.org/x/net v0.17.0 // indirect
62+ golang.org/x/sys v0.13.0 // indirect
63+ golang.org/x/text v0.13.0 // indirect
4864 golang.org/x/time v0.3.0 // indirect
49- golang.org/x/tools v0.7.0 // indirect
65+ golang.org/x/tools v0.14.0 // indirect
5066 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
5167 gotest.tools/v3 v3.5.0 // indirect
5268 )
@@ -4,6 +4,7 @@ go 1.21.3
4 4
5 require (5 require (
6 github.com/PuerkitoBio/goquery v1.8.16 github.com/PuerkitoBio/goquery v1.8.1
7+ github.com/charmbracelet/bubbletea v0.24.2
7 github.com/docker/docker v24.0.6+incompatible8 github.com/docker/docker v24.0.6+incompatible
8 github.com/docker/go-connections v0.4.09 github.com/docker/go-connections v0.4.0
9 github.com/gorilla/sessions v1.2.110 github.com/gorilla/sessions v1.2.1
@@ -15,6 +16,23 @@ require (
15 gopkg.in/yaml.v3 v3.0.116 gopkg.in/yaml.v3 v3.0.1
16 )17 )
17 18
19+require (
20+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
21+ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
22+ github.com/kr/text v0.2.0 // indirect
23+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
24+ github.com/mattn/go-localereader v0.0.1 // indirect
25+ github.com/mattn/go-runewidth v0.0.14 // indirect
26+ github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
27+ github.com/muesli/cancelreader v0.2.2 // indirect
28+ github.com/muesli/reflow v0.3.0 // indirect
29+ github.com/muesli/termenv v0.15.1 // indirect
30+ github.com/rivo/uniseg v0.2.0 // indirect
31+ github.com/stretchr/testify v1.8.2 // indirect
32+ golang.org/x/sync v0.4.0 // indirect
33+ golang.org/x/term v0.13.0 // indirect
34+)
35+
18 require (36 require (
19 github.com/Microsoft/go-winio v0.6.1 // indirect37 github.com/Microsoft/go-winio v0.6.1 // indirect
20 github.com/andybalholm/cascadia v1.3.1 // indirect38 github.com/andybalholm/cascadia v1.3.1 // indirect
@@ -27,7 +45,6 @@ require (
27 github.com/gorilla/context v1.1.1 // indirect45 github.com/gorilla/context v1.1.1 // indirect
28 github.com/gorilla/css v1.0.0 // indirect46 github.com/gorilla/css v1.0.0 // indirect
29 github.com/gorilla/securecookie v1.1.1 // indirect47 github.com/gorilla/securecookie v1.1.1 // indirect
30- github.com/kr/text v0.1.0 // indirect
31 github.com/labstack/gommon v0.4.0 // indirect48 github.com/labstack/gommon v0.4.0 // indirect
32 github.com/mattn/go-colorable v0.1.13 // indirect49 github.com/mattn/go-colorable v0.1.13 // indirect
33 github.com/mattn/go-isatty v0.0.19 // indirect50 github.com/mattn/go-isatty v0.0.19 // indirect
@@ -37,16 +54,15 @@ require (
37 github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect54 github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect
38 github.com/pkg/errors v0.9.1 // indirect55 github.com/pkg/errors v0.9.1 // indirect
39 github.com/rogpeppe/go-internal v1.11.0 // indirect56 github.com/rogpeppe/go-internal v1.11.0 // indirect
40- github.com/stretchr/testify v1.8.2 // indirect
41 github.com/valyala/bytebufferpool v1.0.0 // indirect57 github.com/valyala/bytebufferpool v1.0.0 // indirect
42 github.com/valyala/fasttemplate v1.2.2 // indirect58 github.com/valyala/fasttemplate v1.2.2 // indirect
43- golang.org/x/crypto v0.11.0 // indirect59+ golang.org/x/crypto v0.14.0 // indirect
44- golang.org/x/mod v0.9.0 // indirect60+ golang.org/x/mod v0.13.0 // indirect
45- golang.org/x/net v0.12.0 // indirect61+ golang.org/x/net v0.17.0 // indirect
46- golang.org/x/sys v0.10.0 // indirect62+ golang.org/x/sys v0.13.0 // indirect
47- golang.org/x/text v0.11.0 // indirect63+ golang.org/x/text v0.13.0 // indirect
48 golang.org/x/time v0.3.0 // indirect64 golang.org/x/time v0.3.0 // indirect
49- golang.org/x/tools v0.7.0 // indirect65+ golang.org/x/tools v0.14.0 // indirect
50 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect66 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
51 gotest.tools/v3 v3.5.0 // indirect67 gotest.tools/v3 v3.5.0 // indirect
52 )68 )
M go.sum +44 -17 generated file: collapsed
A internal/cli/cli.go +18 -0
@@ -0,0 +1,18 @@
1+package cli
2+
3+import (
4+ "log"
5+
6+ "github.com/bnema/gordon/internal/cli/cmd"
7+ "github.com/bnema/gordon/internal/cli/mvu"
8+ tea "github.com/charmbracelet/bubbletea"
9+)
10+
11+
12+
13+func InitCli(a *app.App, m *mvu.Model)) {
14+ p := tea.NewProgram(Init, mvu.Init, mvu.Update, mvu.View)
15+ if _, err := p.Run(); err != nil {
16+ log.Fatal(err)
17+ }
18+}
@@ -0,0 +1,18 @@
1+package cli
2+
3+import (
4+ "log"
5+
6+ "github.com/bnema/gordon/internal/cli/cmd"
7+ "github.com/bnema/gordon/internal/cli/mvu"
8+ tea "github.com/charmbracelet/bubbletea"
9+)
10+
11+
12+
13+func InitCli(a *app.App, m *mvu.Model)) {
14+ p := tea.NewProgram(Init, mvu.Init, mvu.Update, mvu.View)
15+ if _, err := p.Run(); err != nil {
16+ log.Fatal(err)
17+ }
18+}
A internal/cli/cmd/checkserver.go +28 -0
@@ -0,0 +1,28 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/app"
9+ tea "github.com/charmbracelet/bubbletea"
10+)
11+
12+type statusMsg int
13+type timeout time.Duration
14+
15+func CheckServer(a *app.App) tea.Msg {
16+ url := a.Config.Admin.Path
17+ c := &http.Client{
18+ Timeout: 10 * time.Second,
19+ }
20+ res, err := c.Get(url)
21+ if err != nil {
22+ fmt.Println(err)
23+ return mvu.ErrMsg{Err: err}
24+ }
25+ defer res.Body.Close() // nolint:errcheck
26+
27+ return statusMsg(res.StatusCode)
28+}
@@ -0,0 +1,28 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/app"
9+ tea "github.com/charmbracelet/bubbletea"
10+)
11+
12+type statusMsg int
13+type timeout time.Duration
14+
15+func CheckServer(a *app.App) tea.Msg {
16+ url := a.Config.Admin.Path
17+ c := &http.Client{
18+ Timeout: 10 * time.Second,
19+ }
20+ res, err := c.Get(url)
21+ if err != nil {
22+ fmt.Println(err)
23+ return mvu.ErrMsg{Err: err}
24+ }
25+ defer res.Body.Close() // nolint:errcheck
26+
27+ return statusMsg(res.StatusCode)
28+}
A internal/cli/cmd/push.go +3 -0
@@ -0,0 +1,3 @@
1+package cmd
2+
3+// the push command is used to push a container image to gordon's endpoint
@@ -0,0 +1,3 @@
1+package cmd
2+
3+// the push command is used to push a container image to gordon's endpoint
A internal/cli/cmd/root.go +8 -0
@@ -0,0 +1,8 @@
1+// the root command is the entrypoint for the gordon cli (default: client)
2+package cmd
3+
4+// if no command selected print all commands
5+// available commands for now are:
6+// - push
7+// - serve
8+// - version
@@ -0,0 +1,8 @@
1+// the root command is the entrypoint for the gordon cli (default: client)
2+package cmd
3+
4+// if no command selected print all commands
5+// available commands for now are:
6+// - push
7+// - serve
8+// - version
A internal/cli/cmd/serve.go +3 -0
@@ -0,0 +1,3 @@
1+// the serve command is used to start the gordon server
2+// Path: internal/cli/cmd/serve.go
3+package cmd
@@ -0,0 +1,3 @@
1+// the serve command is used to start the gordon server
2+// Path: internal/cli/cmd/serve.go
3+package cmd
A internal/cli/cmd/update.go +406 -0
@@ -0,0 +1,406 @@
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
5+package 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+// }
@@ -0,0 +1,406 @@
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
5+package 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+// }
A internal/cli/mvu/mvu.go +59 -0
@@ -0,0 +1,59 @@
1+package mvu
2+
3+import (
4+ "fmt"
5+ "net/http"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+)
9+
10+type Model struct {
11+ status int
12+ err error
13+ url string
14+}
15+
16+type statusMsg int
17+
18+type ErrMsg struct {
19+ Err error
20+}
21+
22+func (m Model) Init() tea.Cmd {
23+ return nil
24+}
25+
26+func Error(e ErrMsg) string { return e.Err.Error() }
27+
28+func Update(msg tea.Msg, m Model) (tea.Model, tea.Cmd) {
29+ switch msg := msg.(type) {
30+ case tea.KeyMsg:
31+ switch msg.String() {
32+ case "q", "ctrl+c", "esc":
33+ return m, tea.Quit
34+ default:
35+ return nil
36+ }
37+
38+ case statusMsg:
39+ m.status = int(msg)
40+ return m, tea.Quit
41+
42+ case ErrMsg:
43+ m.err = msg
44+ return nil
45+
46+ default:
47+ return nil
48+ }
49+}
50+
51+func (m Model) View() string {
52+ s := fmt.Sprintf("Checking %s...", m.url)
53+ if m.err != nil {
54+ s += fmt.Sprintf("something went wrong: %s", m.err)
55+ } else if m.status != 0 {
56+ s += fmt.Sprintf("%d %s", m.status, http.StatusText(m.status))
57+ }
58+ return s + "\n"
59+}
@@ -0,0 +1,59 @@
1+package mvu
2+
3+import (
4+ "fmt"
5+ "net/http"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+)
9+
10+type Model struct {
11+ status int
12+ err error
13+ url string
14+}
15+
16+type statusMsg int
17+
18+type ErrMsg struct {
19+ Err error
20+}
21+
22+func (m Model) Init() tea.Cmd {
23+ return nil
24+}
25+
26+func Error(e ErrMsg) string { return e.Err.Error() }
27+
28+func Update(msg tea.Msg, m Model) (tea.Model, tea.Cmd) {
29+ switch msg := msg.(type) {
30+ case tea.KeyMsg:
31+ switch msg.String() {
32+ case "q", "ctrl+c", "esc":
33+ return m, tea.Quit
34+ default:
35+ return nil
36+ }
37+
38+ case statusMsg:
39+ m.status = int(msg)
40+ return m, tea.Quit
41+
42+ case ErrMsg:
43+ m.err = msg
44+ return nil
45+
46+ default:
47+ return nil
48+ }
49+}
50+
51+func (m Model) View() string {
52+ s := fmt.Sprintf("Checking %s...", m.url)
53+ if m.err != nil {
54+ s += fmt.Sprintf("something went wrong: %s", m.err)
55+ } else if m.status != 0 {
56+ s += fmt.Sprintf("%d %s", m.status, http.StatusText(m.status))
57+ }
58+ return s + "\n"
59+}
D internal/commands/update.go +0 -404
@@ -1,404 +0,0 @@
1-// Heavily inspired by Pocketbase's update command
2-// See: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
3-package commands
4-
5-import (
6- "context"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "io"
11- "log"
12- "net/http"
13- "os"
14- "path/filepath"
15- "runtime"
16- "strconv"
17- "strings"
18-
19- "github.com/AlecAivazis/survey/v2"
20- "github.com/fatih/color"
21- "github.com/pocketbase/pocketbase/core"
22- "github.com/pocketbase/pocketbase/tools/archive"
23- "github.com/spf13/cobra"
24-)
25-
26-// HttpClient is a base HTTP client interface (usually used for test purposes).
27-type HttpClient interface {
28- Do(req *http.Request) (*http.Response, error)
29-}
30-
31-// Config defines the config options of the ghupdate plugin.
32-//
33-// NB! This plugin is considered experimental and its config options may change in the future.
34-type Config struct {
35- // Owner specifies the account owner of the repository (default to "pocketbase").
36- Owner string
37-
38- // Repo specifies the name of the repository (default to "pocketbase").
39- Repo string
40-
41- // ArchiveExecutable specifies the name of the executable file in the release archive
42- // (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
43- ArchiveExecutable string
44-
45- // Optional context to use when fetching and downloading the latest release.
46- Context context.Context
47-
48- // The HTTP client to use when fetching and downloading the latest release.
49- // Defaults to `http.DefaultClient`.
50- HttpClient HttpClient
51-}
52-
53-// MustRegister registers the ghupdate plugin to the provided app instance
54-// and panic if it fails.
55-func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
56- if err := Register(app, rootCmd, config); err != nil {
57- panic(err)
58- }
59-}
60-
61-// Register registers the ghupdate plugin to the provided app instance.
62-func Register(app core.App, rootCmd *cobra.Command, config Config) error {
63- p := &plugin{
64- app: app,
65- currentVersion: rootCmd.Version,
66- config: config,
67- }
68-
69- if p.config.Owner == "" {
70- p.config.Owner = "pocketbase"
71- }
72-
73- if p.config.Repo == "" {
74- p.config.Repo = "pocketbase"
75- }
76-
77- if p.config.ArchiveExecutable == "" {
78- p.config.ArchiveExecutable = "pocketbase"
79- }
80-
81- if p.config.HttpClient == nil {
82- p.config.HttpClient = http.DefaultClient
83- }
84-
85- if p.config.Context == nil {
86- p.config.Context = context.Background()
87- }
88-
89- rootCmd.AddCommand(p.updateCmd())
90-
91- return nil
92-}
93-
94-type plugin struct {
95- app core.App
96- currentVersion string
97- config Config
98-}
99-
100-func (p *plugin) updateCmd() *cobra.Command {
101- var withBackup bool
102-
103- command := &cobra.Command{
104- Use: "update",
105- Short: "Automatically updates the current PocketBase executable with the latest available version",
106- // @todo remove after logs generalization
107- // prevents printing the error log twice
108- SilenceErrors: true,
109- SilenceUsage: true,
110- RunE: func(command *cobra.Command, args []string) error {
111- var needConfirm bool
112- if isMaybeRunningInDocker() {
113- needConfirm = true
114- color.Yellow("NB! It seems that you are in a Docker container.")
115- 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.")
116- } else if isMaybeRunningInNixOS() {
117- needConfirm = true
118- color.Yellow("NB! It seems that you are in a NixOS.")
119- color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
120- }
121-
122- if needConfirm {
123- confirm := false
124- prompt := &survey.Confirm{
125- Message: "Do you want to proceed with the update?",
126- }
127- survey.AskOne(prompt, &confirm)
128- if !confirm {
129- fmt.Println("The command has been cancelled.")
130- return nil
131- }
132- }
133-
134- return p.update(withBackup)
135- },
136- }
137-
138- command.PersistentFlags().BoolVar(
139- &withBackup,
140- "backup",
141- true,
142- "Creates a pb_data backup at the end of the update process",
143- )
144-
145- return command
146-}
147-
148-func (p *plugin) update(withBackup bool) error {
149- color.Yellow("Fetching release information...")
150-
151- latest, err := fetchLatestRelease(
152- p.config.Context,
153- p.config.HttpClient,
154- p.config.Owner,
155- p.config.Repo,
156- )
157- if err != nil {
158- return err
159- }
160-
161- if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
162- color.Green("You already have the latest PocketBase %s.", p.currentVersion)
163- return nil
164- }
165-
166- suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
167- if suffix == "" {
168- return errors.New("unsupported platform")
169- }
170-
171- asset, err := latest.findAssetBySuffix(suffix)
172- if err != nil {
173- return err
174- }
175-
176- releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
177- defer os.RemoveAll(releaseDir)
178-
179- color.Yellow("Downloading %s...", asset.Name)
180-
181- // download the release asset
182- assetZip := filepath.Join(releaseDir, asset.Name)
183- if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
184- return err
185- }
186-
187- color.Yellow("Extracting %s...", asset.Name)
188-
189- extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
190- defer os.RemoveAll(extractDir)
191-
192- if err := archive.Extract(assetZip, extractDir); err != nil {
193- return err
194- }
195-
196- color.Yellow("Replacing the executable...")
197-
198- oldExec, err := os.Executable()
199- if err != nil {
200- return err
201- }
202- renamedOldExec := oldExec + ".old"
203- defer os.Remove(renamedOldExec)
204-
205- newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
206- if _, err := os.Stat(newExec); err != nil {
207- // try again with an .exe extension
208- newExec = newExec + ".exe"
209- if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
210- return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
211- }
212- }
213-
214- // rename the current executable
215- if err := os.Rename(oldExec, renamedOldExec); err != nil {
216- return fmt.Errorf("Failed to rename the current executable: %w", err)
217- }
218-
219- tryToRevertExecChanges := func() {
220- if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
221- log.Println(revertErr)
222- }
223- }
224-
225- // replace with the extracted binary
226- if err := os.Rename(newExec, oldExec); err != nil {
227- tryToRevertExecChanges()
228- return fmt.Errorf("Failed replacing the executable: %w", err)
229- }
230-
231- if withBackup {
232- color.Yellow("Creating pb_data backup...")
233-
234- backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
235- if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
236- tryToRevertExecChanges()
237- return err
238- }
239- }
240-
241- color.HiBlack("---")
242- color.Green("Update completed successfully! You can start the executable as usual.")
243-
244- return nil
245-}
246-
247-func fetchLatestRelease(
248- ctx context.Context,
249- client HttpClient,
250- owner string,
251- repo string,
252-) (*release, error) {
253- url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
254-
255- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
256- if err != nil {
257- return nil, err
258- }
259-
260- res, err := client.Do(req)
261- if err != nil {
262- return nil, err
263- }
264- defer res.Body.Close()
265-
266- rawBody, err := io.ReadAll(res.Body)
267- if err != nil {
268- return nil, err
269- }
270-
271- // http.Client doesn't treat non 2xx responses as error
272- if res.StatusCode >= 400 {
273- return nil, fmt.Errorf(
274- "(%d) failed to fetch latest releases:\n%s",
275- res.StatusCode,
276- string(rawBody),
277- )
278- }
279-
280- result := &release{}
281- if err := json.Unmarshal(rawBody, result); err != nil {
282- return nil, err
283- }
284-
285- return result, nil
286-}
287-
288-func downloadFile(
289- ctx context.Context,
290- client HttpClient,
291- url string,
292- destPath string,
293-) error {
294- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
295- if err != nil {
296- return err
297- }
298-
299- res, err := client.Do(req)
300- if err != nil {
301- return err
302- }
303- defer res.Body.Close()
304-
305- // http.Client doesn't treat non 2xx responses as error
306- if res.StatusCode >= 400 {
307- return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
308- }
309-
310- // ensure that the dest parent dir(s) exist
311- if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
312- return err
313- }
314-
315- dest, err := os.Create(destPath)
316- if err != nil {
317- return err
318- }
319- defer dest.Close()
320-
321- if _, err := io.Copy(dest, res.Body); err != nil {
322- return err
323- }
324-
325- return nil
326-}
327-
328-func archiveSuffix(goos, goarch string) string {
329- switch goos {
330- case "linux":
331- switch goarch {
332- case "amd64":
333- return "_linux_amd64.zip"
334- case "arm64":
335- return "_linux_arm64.zip"
336- case "arm":
337- return "_linux_armv7.zip"
338- }
339- case "darwin":
340- switch goarch {
341- case "amd64":
342- return "_darwin_amd64.zip"
343- case "arm64":
344- return "_darwin_arm64.zip"
345- }
346- case "windows":
347- switch goarch {
348- case "amd64":
349- return "_windows_amd64.zip"
350- case "arm64":
351- return "_windows_arm64.zip"
352- }
353- }
354-
355- return ""
356-}
357-
358-func compareVersions(a, b string) int {
359- aSplit := strings.Split(a, ".")
360- aTotal := len(aSplit)
361-
362- bSplit := strings.Split(b, ".")
363- bTotal := len(bSplit)
364-
365- limit := aTotal
366- if bTotal > aTotal {
367- limit = bTotal
368- }
369-
370- for i := 0; i < limit; i++ {
371- var x, y int
372-
373- if i < aTotal {
374- x, _ = strconv.Atoi(aSplit[i])
375- }
376-
377- if i < bTotal {
378- y, _ = strconv.Atoi(bSplit[i])
379- }
380-
381- if x < y {
382- return 1 // b is newer
383- }
384-
385- if x > y {
386- return -1 // a is newer
387- }
388- }
389-
390- return 0 // equal
391-}
392-
393-// note: not completely reliable as it may not work on all platforms
394-// but should at least provide a warning for the most common use cases
395-func isMaybeRunningInDocker() bool {
396- _, err := os.Stat("/.dockerenv")
397- return err == nil
398-}
399-
400-// note: untested
401-func isMaybeRunningInNixOS() bool {
402- _, err := os.Stat("/etc/NIXOS")
403- return err == nil
404-}
@@ -1,404 +0,0 @@
1-// Heavily inspired by Pocketbase's update command
2-// See: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
3-package commands
4-
5-import (
6- "context"
7- "encoding/json"
8- "errors"
9- "fmt"
10- "io"
11- "log"
12- "net/http"
13- "os"
14- "path/filepath"
15- "runtime"
16- "strconv"
17- "strings"
18-
19- "github.com/AlecAivazis/survey/v2"
20- "github.com/fatih/color"
21- "github.com/pocketbase/pocketbase/core"
22- "github.com/pocketbase/pocketbase/tools/archive"
23- "github.com/spf13/cobra"
24-)
25-
26-// HttpClient is a base HTTP client interface (usually used for test purposes).
27-type HttpClient interface {
28- Do(req *http.Request) (*http.Response, error)
29-}
30-
31-// Config defines the config options of the ghupdate plugin.
32-//
33-// NB! This plugin is considered experimental and its config options may change in the future.
34-type Config struct {
35- // Owner specifies the account owner of the repository (default to "pocketbase").
36- Owner string
37-
38- // Repo specifies the name of the repository (default to "pocketbase").
39- Repo string
40-
41- // ArchiveExecutable specifies the name of the executable file in the release archive
42- // (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
43- ArchiveExecutable string
44-
45- // Optional context to use when fetching and downloading the latest release.
46- Context context.Context
47-
48- // The HTTP client to use when fetching and downloading the latest release.
49- // Defaults to `http.DefaultClient`.
50- HttpClient HttpClient
51-}
52-
53-// MustRegister registers the ghupdate plugin to the provided app instance
54-// and panic if it fails.
55-func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
56- if err := Register(app, rootCmd, config); err != nil {
57- panic(err)
58- }
59-}
60-
61-// Register registers the ghupdate plugin to the provided app instance.
62-func Register(app core.App, rootCmd *cobra.Command, config Config) error {
63- p := &plugin{
64- app: app,
65- currentVersion: rootCmd.Version,
66- config: config,
67- }
68-
69- if p.config.Owner == "" {
70- p.config.Owner = "pocketbase"
71- }
72-
73- if p.config.Repo == "" {
74- p.config.Repo = "pocketbase"
75- }
76-
77- if p.config.ArchiveExecutable == "" {
78- p.config.ArchiveExecutable = "pocketbase"
79- }
80-
81- if p.config.HttpClient == nil {
82- p.config.HttpClient = http.DefaultClient
83- }
84-
85- if p.config.Context == nil {
86- p.config.Context = context.Background()
87- }
88-
89- rootCmd.AddCommand(p.updateCmd())
90-
91- return nil
92-}
93-
94-type plugin struct {
95- app core.App
96- currentVersion string
97- config Config
98-}
99-
100-func (p *plugin) updateCmd() *cobra.Command {
101- var withBackup bool
102-
103- command := &cobra.Command{
104- Use: "update",
105- Short: "Automatically updates the current PocketBase executable with the latest available version",
106- // @todo remove after logs generalization
107- // prevents printing the error log twice
108- SilenceErrors: true,
109- SilenceUsage: true,
110- RunE: func(command *cobra.Command, args []string) error {
111- var needConfirm bool
112- if isMaybeRunningInDocker() {
113- needConfirm = true
114- color.Yellow("NB! It seems that you are in a Docker container.")
115- 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.")
116- } else if isMaybeRunningInNixOS() {
117- needConfirm = true
118- color.Yellow("NB! It seems that you are in a NixOS.")
119- color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
120- }
121-
122- if needConfirm {
123- confirm := false
124- prompt := &survey.Confirm{
125- Message: "Do you want to proceed with the update?",
126- }
127- survey.AskOne(prompt, &confirm)
128- if !confirm {
129- fmt.Println("The command has been cancelled.")
130- return nil
131- }
132- }
133-
134- return p.update(withBackup)
135- },
136- }
137-
138- command.PersistentFlags().BoolVar(
139- &withBackup,
140- "backup",
141- true,
142- "Creates a pb_data backup at the end of the update process",
143- )
144-
145- return command
146-}
147-
148-func (p *plugin) update(withBackup bool) error {
149- color.Yellow("Fetching release information...")
150-
151- latest, err := fetchLatestRelease(
152- p.config.Context,
153- p.config.HttpClient,
154- p.config.Owner,
155- p.config.Repo,
156- )
157- if err != nil {
158- return err
159- }
160-
161- if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
162- color.Green("You already have the latest PocketBase %s.", p.currentVersion)
163- return nil
164- }
165-
166- suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
167- if suffix == "" {
168- return errors.New("unsupported platform")
169- }
170-
171- asset, err := latest.findAssetBySuffix(suffix)
172- if err != nil {
173- return err
174- }
175-
176- releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
177- defer os.RemoveAll(releaseDir)
178-
179- color.Yellow("Downloading %s...", asset.Name)
180-
181- // download the release asset
182- assetZip := filepath.Join(releaseDir, asset.Name)
183- if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
184- return err
185- }
186-
187- color.Yellow("Extracting %s...", asset.Name)
188-
189- extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
190- defer os.RemoveAll(extractDir)
191-
192- if err := archive.Extract(assetZip, extractDir); err != nil {
193- return err
194- }
195-
196- color.Yellow("Replacing the executable...")
197-
198- oldExec, err := os.Executable()
199- if err != nil {
200- return err
201- }
202- renamedOldExec := oldExec + ".old"
203- defer os.Remove(renamedOldExec)
204-
205- newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
206- if _, err := os.Stat(newExec); err != nil {
207- // try again with an .exe extension
208- newExec = newExec + ".exe"
209- if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
210- return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
211- }
212- }
213-
214- // rename the current executable
215- if err := os.Rename(oldExec, renamedOldExec); err != nil {
216- return fmt.Errorf("Failed to rename the current executable: %w", err)
217- }
218-
219- tryToRevertExecChanges := func() {
220- if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
221- log.Println(revertErr)
222- }
223- }
224-
225- // replace with the extracted binary
226- if err := os.Rename(newExec, oldExec); err != nil {
227- tryToRevertExecChanges()
228- return fmt.Errorf("Failed replacing the executable: %w", err)
229- }
230-
231- if withBackup {
232- color.Yellow("Creating pb_data backup...")
233-
234- backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
235- if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
236- tryToRevertExecChanges()
237- return err
238- }
239- }
240-
241- color.HiBlack("---")
242- color.Green("Update completed successfully! You can start the executable as usual.")
243-
244- return nil
245-}
246-
247-func fetchLatestRelease(
248- ctx context.Context,
249- client HttpClient,
250- owner string,
251- repo string,
252-) (*release, error) {
253- url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
254-
255- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
256- if err != nil {
257- return nil, err
258- }
259-
260- res, err := client.Do(req)
261- if err != nil {
262- return nil, err
263- }
264- defer res.Body.Close()
265-
266- rawBody, err := io.ReadAll(res.Body)
267- if err != nil {
268- return nil, err
269- }
270-
271- // http.Client doesn't treat non 2xx responses as error
272- if res.StatusCode >= 400 {
273- return nil, fmt.Errorf(
274- "(%d) failed to fetch latest releases:\n%s",
275- res.StatusCode,
276- string(rawBody),
277- )
278- }
279-
280- result := &release{}
281- if err := json.Unmarshal(rawBody, result); err != nil {
282- return nil, err
283- }
284-
285- return result, nil
286-}
287-
288-func downloadFile(
289- ctx context.Context,
290- client HttpClient,
291- url string,
292- destPath string,
293-) error {
294- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
295- if err != nil {
296- return err
297- }
298-
299- res, err := client.Do(req)
300- if err != nil {
301- return err
302- }
303- defer res.Body.Close()
304-
305- // http.Client doesn't treat non 2xx responses as error
306- if res.StatusCode >= 400 {
307- return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
308- }
309-
310- // ensure that the dest parent dir(s) exist
311- if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
312- return err
313- }
314-
315- dest, err := os.Create(destPath)
316- if err != nil {
317- return err
318- }
319- defer dest.Close()
320-
321- if _, err := io.Copy(dest, res.Body); err != nil {
322- return err
323- }
324-
325- return nil
326-}
327-
328-func archiveSuffix(goos, goarch string) string {
329- switch goos {
330- case "linux":
331- switch goarch {
332- case "amd64":
333- return "_linux_amd64.zip"
334- case "arm64":
335- return "_linux_arm64.zip"
336- case "arm":
337- return "_linux_armv7.zip"
338- }
339- case "darwin":
340- switch goarch {
341- case "amd64":
342- return "_darwin_amd64.zip"
343- case "arm64":
344- return "_darwin_arm64.zip"
345- }
346- case "windows":
347- switch goarch {
348- case "amd64":
349- return "_windows_amd64.zip"
350- case "arm64":
351- return "_windows_arm64.zip"
352- }
353- }
354-
355- return ""
356-}
357-
358-func compareVersions(a, b string) int {
359- aSplit := strings.Split(a, ".")
360- aTotal := len(aSplit)
361-
362- bSplit := strings.Split(b, ".")
363- bTotal := len(bSplit)
364-
365- limit := aTotal
366- if bTotal > aTotal {
367- limit = bTotal
368- }
369-
370- for i := 0; i < limit; i++ {
371- var x, y int
372-
373- if i < aTotal {
374- x, _ = strconv.Atoi(aSplit[i])
375- }
376-
377- if i < bTotal {
378- y, _ = strconv.Atoi(bSplit[i])
379- }
380-
381- if x < y {
382- return 1 // b is newer
383- }
384-
385- if x > y {
386- return -1 // a is newer
387- }
388- }
389-
390- return 0 // equal
391-}
392-
393-// note: not completely reliable as it may not work on all platforms
394-// but should at least provide a warning for the most common use cases
395-func isMaybeRunningInDocker() bool {
396- _, err := os.Stat("/.dockerenv")
397- return err == nil
398-}
399-
400-// note: untested
401-func isMaybeRunningInNixOS() bool {
402- _, err := os.Stat("/etc/NIXOS")
403- return err == nil
404-}

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