detached 6ca3069

Now compile also for Windows and macOS, implement update command, refactor CLI and API Client, and various bug fixes

- Add support for Windows and Darwin in GoReleaser configuration
- Implement the Update command in CLI
- Enhance ping command output with humanized uptime and server version
- Update serve command with conditional default port for Docker environments
- Extend Config with GetStorageDir method for storage directory retrieval
- Removed unused /api/hello endpoint in favor of /api/ping
- Improve api token middleware to return status code

Now compile also for Windows and macOS, implement update command, refactor CLI and API Client, and various bug fixes

- Add support for Windows and Darwin in GoReleaser configuration
- Implement the Update command in CLI
- Enhance ping command output with humanized uptime and server version
- Update serve command with conditional default port for Docker environments
- Extend Config with GetStorageDir method for storage directory retrieval
- Removed unused /api/hello endpoint in favor of /api/ping
- Improve api token middleware to return status code
commit
6ca3069799b6a12476937fcc79d124f608a817b8
author
bnema · · 2023-11-06 16:12 UTC
committer
bnema · · 2023-11-06 16:19 UTC
parent
e712186
signature
unsigned
12 files · +463 -430
M .goreleaser.yaml +3 -1
@@ -8,6 +8,8 @@ builds:
88 main: ./cmd/cli/main.go
99 goos:
1010 - linux
11+ - windows
12+ - darwin
1113 goarch:
1214 - amd64
1315 - arm64
@@ -55,7 +57,7 @@ docker_manifests:
5557 create_flags: ["--amend"]
5658
5759 archives:
58- - format: tar.gz
60+ - format: zip
5961 name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
6062
6163 changelog:
@@ -8,6 +8,8 @@ builds:
8 main: ./cmd/cli/main.go8 main: ./cmd/cli/main.go
9 goos:9 goos:
10 - linux10 - linux
11+ - windows
12+ - darwin
11 goarch:13 goarch:
12 - amd6414 - amd64
13 - arm6415 - arm64
@@ -55,7 +57,7 @@ docker_manifests:
55 create_flags: ["--amend"]57 create_flags: ["--amend"]
56 58
57 archives:59 archives:
58- - format: tar.gz60+ - format: zip
59 name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"61 name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
60 62
61 changelog:63 changelog:
M cmd/cli/main.go +7 -6
@@ -20,14 +20,15 @@ var (
2020
2121 var rootCmd = &cobra.Command{Use: "gordon"}
2222
23-func InitializeCommands(a *cli.App, s *server.App) {
24- rootCmd.AddCommand(cmd.NewServeCommand(s))
25- rootCmd.AddCommand(cmd.NewPingCommand(a))
26- rootCmd.AddCommand(cmd.NewPushCommand(a))
23+func InitializeCommands(client *cli.App, server *server.App) {
24+ rootCmd.AddCommand(cmd.NewServeCommand(server))
25+ rootCmd.AddCommand(cmd.NewPingCommand(client))
26+ rootCmd.AddCommand(cmd.NewPushCommand(client))
27+ rootCmd.AddCommand(cmd.NewUpdateCommand(client))
2728 }
2829
29-func Execute(a *cli.App, s *server.App) {
30- InitializeCommands(a, s)
30+func Execute(client *cli.App, server *server.App) {
31+ InitializeCommands(client, server)
3132 cobra.CheckErr(rootCmd.Execute())
3233 }
3334
@@ -20,14 +20,15 @@ var (
20 20
21 var rootCmd = &cobra.Command{Use: "gordon"}21 var rootCmd = &cobra.Command{Use: "gordon"}
22 22
23-func InitializeCommands(a *cli.App, s *server.App) {23+func InitializeCommands(client *cli.App, server *server.App) {
24- rootCmd.AddCommand(cmd.NewServeCommand(s))24+ rootCmd.AddCommand(cmd.NewServeCommand(server))
25- rootCmd.AddCommand(cmd.NewPingCommand(a))25+ rootCmd.AddCommand(cmd.NewPingCommand(client))
26- rootCmd.AddCommand(cmd.NewPushCommand(a))26+ rootCmd.AddCommand(cmd.NewPushCommand(client))
27+ rootCmd.AddCommand(cmd.NewUpdateCommand(client))
27 }28 }
28 29
29-func Execute(a *cli.App, s *server.App) {30+func Execute(client *cli.App, server *server.App) {
30- InitializeCommands(a, s)31+ InitializeCommands(client, server)
31 cobra.CheckErr(rootCmd.Execute())32 cobra.CheckErr(rootCmd.Execute())
32 }33 }
33 34
M go.mod +6 -1
@@ -3,30 +3,35 @@ module github.com/bnema/gordon
33 go 1.21.3
44
55 require (
6+ github.com/AlecAivazis/survey/v2 v2.3.7
67 github.com/PuerkitoBio/goquery v1.8.1
78 github.com/cheggaaa/pb v1.0.29
89 github.com/docker/docker v24.0.6+incompatible
910 github.com/docker/go-connections v0.4.0
11+ github.com/fatih/color v1.15.0
1012 github.com/gorilla/sessions v1.2.1
1113 github.com/joho/godotenv v1.5.1
1214 github.com/labstack/echo-contrib v0.15.0
1315 github.com/labstack/echo/v4 v4.11.2
1416 github.com/microcosm-cc/bluemonday v1.0.25
17+ github.com/pocketbase/pocketbase v0.19.3
1518 github.com/spf13/cobra v1.7.0
1619 gopkg.in/yaml.v3 v3.0.1
1720 modernc.org/sqlite v1.26.0
1821 )
1922
2023 require (
24+ github.com/creack/pty v1.1.18 // indirect
2125 github.com/dustin/go-humanize v1.0.1 // indirect
2226 github.com/inconshreveable/mousetrap v1.1.0 // indirect
2327 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
2428 github.com/kr/text v0.2.0 // indirect
2529 github.com/mattn/go-runewidth v0.0.15 // indirect
26- github.com/mattn/go-sqlite3 v1.14.17 // indirect
30+ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
2731 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
2832 github.com/rivo/uniseg v0.4.4 // indirect
2933 github.com/spf13/pflag v1.0.5 // indirect
34+ golang.org/x/term v0.13.0 // indirect
3035 lukechampine.com/uint128 v1.3.0 // indirect
3136 modernc.org/cc/v3 v3.41.0 // indirect
3237 modernc.org/ccgo/v3 v3.16.15 // indirect
@@ -3,30 +3,35 @@ module github.com/bnema/gordon
3 go 1.21.33 go 1.21.3
4 4
5 require (5 require (
6+ github.com/AlecAivazis/survey/v2 v2.3.7
6 github.com/PuerkitoBio/goquery v1.8.17 github.com/PuerkitoBio/goquery v1.8.1
7 github.com/cheggaaa/pb v1.0.298 github.com/cheggaaa/pb v1.0.29
8 github.com/docker/docker v24.0.6+incompatible9 github.com/docker/docker v24.0.6+incompatible
9 github.com/docker/go-connections v0.4.010 github.com/docker/go-connections v0.4.0
11+ github.com/fatih/color v1.15.0
10 github.com/gorilla/sessions v1.2.112 github.com/gorilla/sessions v1.2.1
11 github.com/joho/godotenv v1.5.113 github.com/joho/godotenv v1.5.1
12 github.com/labstack/echo-contrib v0.15.014 github.com/labstack/echo-contrib v0.15.0
13 github.com/labstack/echo/v4 v4.11.215 github.com/labstack/echo/v4 v4.11.2
14 github.com/microcosm-cc/bluemonday v1.0.2516 github.com/microcosm-cc/bluemonday v1.0.25
17+ github.com/pocketbase/pocketbase v0.19.3
15 github.com/spf13/cobra v1.7.018 github.com/spf13/cobra v1.7.0
16 gopkg.in/yaml.v3 v3.0.119 gopkg.in/yaml.v3 v3.0.1
17 modernc.org/sqlite v1.26.020 modernc.org/sqlite v1.26.0
18 )21 )
19 22
20 require (23 require (
24+ github.com/creack/pty v1.1.18 // indirect
21 github.com/dustin/go-humanize v1.0.1 // indirect25 github.com/dustin/go-humanize v1.0.1 // indirect
22 github.com/inconshreveable/mousetrap v1.1.0 // indirect26 github.com/inconshreveable/mousetrap v1.1.0 // indirect
23 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect27 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
24 github.com/kr/text v0.2.0 // indirect28 github.com/kr/text v0.2.0 // indirect
25 github.com/mattn/go-runewidth v0.0.15 // indirect29 github.com/mattn/go-runewidth v0.0.15 // indirect
26- github.com/mattn/go-sqlite3 v1.14.17 // indirect30+ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
27 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect31 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
28 github.com/rivo/uniseg v0.4.4 // indirect32 github.com/rivo/uniseg v0.4.4 // indirect
29 github.com/spf13/pflag v1.0.5 // indirect33 github.com/spf13/pflag v1.0.5 // indirect
34+ golang.org/x/term v0.13.0 // indirect
30 lukechampine.com/uint128 v1.3.0 // indirect35 lukechampine.com/uint128 v1.3.0 // indirect
31 modernc.org/cc/v3 v3.41.0 // indirect36 modernc.org/cc/v3 v3.41.0 // indirect
32 modernc.org/ccgo/v3 v3.16.15 // indirect37 modernc.org/ccgo/v3 v3.16.15 // indirect
M go.sum +23 -3 generated file: collapsed
D internal/cli/cmd/_update.go +0 -406
@@ -1,406 +0,0 @@
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-// }
@@ -1,406 +0,0 @@
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-// }
M internal/cli/cmd/ping.go +38 -1
@@ -1,16 +1,25 @@
11 package cmd
22
33 import (
4+ "encoding/json"
45 "fmt"
56 "os"
7+ "time"
68
79 "github.com/bnema/gordon/internal/cli"
810 "github.com/bnema/gordon/internal/cli/handler"
911 "github.com/bnema/gordon/internal/common"
12+ "github.com/bnema/gordon/pkg/humanize"
1013
1114 "github.com/spf13/cobra"
1215 )
1316
17+// Define a struct to match the JSON response structure
18+type PingResponse struct {
19+ Uptime string `json:"uptime"`
20+ Version string `json:"version"`
21+}
22+
1423 func NewPingCommand(a *cli.App) *cobra.Command {
1524 // handler.FieldCheck(a)
1625 return &cobra.Command{
@@ -42,7 +51,35 @@ func NewPingCommand(a *cli.App) *cobra.Command {
4251 return
4352 }
4453
45- fmt.Print(string(resp.Body))
54+ if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 404 {
55+ fmt.Println("Login incorrect, edit your config file and try again")
56+ return
57+ }
58+
59+ // Unmarshal the JSON response into the PingResponse struct
60+ var pingResp PingResponse
61+ err = json.Unmarshal(resp.Body, &pingResp)
62+ if err != nil {
63+ fmt.Println("Error unmarshalling response:", err)
64+ return
65+ }
66+
67+ // Parse the uptime duration string
68+ duration, err := time.ParseDuration(pingResp.Uptime)
69+ if err != nil {
70+ fmt.Println("Error parsing uptime duration:", err)
71+ return
72+ }
73+
74+ // Calculate the start time of the server
75+ startTime := time.Now().Add(-duration)
76+
77+ // Use humanize to get a human-readable representation of the start time
78+ humanizedUptime := humanize.TimeAgo(startTime)
79+
80+ /// Print the information
81+ fmt.Printf("Pong!\nServer up since: %s\nServer version: %s\n", humanizedUptime, pingResp.Version)
82+
4683 },
4784 }
4885 }
@@ -1,16 +1,25 @@
1 package cmd1 package cmd
2 2
3 import (3 import (
4+ "encoding/json"
4 "fmt"5 "fmt"
5 "os"6 "os"
7+ "time"
6 8
7 "github.com/bnema/gordon/internal/cli"9 "github.com/bnema/gordon/internal/cli"
8 "github.com/bnema/gordon/internal/cli/handler"10 "github.com/bnema/gordon/internal/cli/handler"
9 "github.com/bnema/gordon/internal/common"11 "github.com/bnema/gordon/internal/common"
12+ "github.com/bnema/gordon/pkg/humanize"
10 13
11 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
12 )15 )
13 16
17+// Define a struct to match the JSON response structure
18+type PingResponse struct {
19+ Uptime string `json:"uptime"`
20+ Version string `json:"version"`
21+}
22+
14 func NewPingCommand(a *cli.App) *cobra.Command {23 func NewPingCommand(a *cli.App) *cobra.Command {
15 // handler.FieldCheck(a)24 // handler.FieldCheck(a)
16 return &cobra.Command{25 return &cobra.Command{
@@ -42,7 +51,35 @@ func NewPingCommand(a *cli.App) *cobra.Command {
42 return51 return
43 }52 }
44 53
45- fmt.Print(string(resp.Body))54+ if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 404 {
55+ fmt.Println("Login incorrect, edit your config file and try again")
56+ return
57+ }
58+
59+ // Unmarshal the JSON response into the PingResponse struct
60+ var pingResp PingResponse
61+ err = json.Unmarshal(resp.Body, &pingResp)
62+ if err != nil {
63+ fmt.Println("Error unmarshalling response:", err)
64+ return
65+ }
66+
67+ // Parse the uptime duration string
68+ duration, err := time.ParseDuration(pingResp.Uptime)
69+ if err != nil {
70+ fmt.Println("Error parsing uptime duration:", err)
71+ return
72+ }
73+
74+ // Calculate the start time of the server
75+ startTime := time.Now().Add(-duration)
76+
77+ // Use humanize to get a human-readable representation of the start time
78+ humanizedUptime := humanize.TimeAgo(startTime)
79+
80+ /// Print the information
81+ fmt.Printf("Pong!\nServer up since: %s\nServer version: %s\n", humanizedUptime, pingResp.Version)
82+
46 },83 },
47 }84 }
48 }85 }
M internal/cli/cmd/serve.go +8 -2
@@ -13,10 +13,16 @@ func NewServeCommand(a *server.App) *cobra.Command {
1313 defaultport := "1323"
1414 var port string
1515
16+ // if the server is running in a docker container, the default port is 80
17+ if isMaybeRunningInDocker() {
18+ defaultport = "80"
19+ }
20+
1621 // if no flags -p or --port are specified, the default port is used
1722 serveCmd := &cobra.Command{
18- Use: "serve",
19- Args: cobra.NoArgs,
23+ Use: "serve",
24+ Short: "Start the Gordon server",
25+ Args: cobra.NoArgs,
2026 Run: func(cmd *cobra.Command, args []string) {
2127 // handler.StartServer will use the value of port, which will be the default
2228 // value if no flag is specified
@@ -13,10 +13,16 @@ func NewServeCommand(a *server.App) *cobra.Command {
13 defaultport := "1323"13 defaultport := "1323"
14 var port string14 var port string
15 15
16+ // if the server is running in a docker container, the default port is 80
17+ if isMaybeRunningInDocker() {
18+ defaultport = "80"
19+ }
20+
16 // if no flags -p or --port are specified, the default port is used21 // if no flags -p or --port are specified, the default port is used
17 serveCmd := &cobra.Command{22 serveCmd := &cobra.Command{
18- Use: "serve",23+ Use: "serve",
19- Args: cobra.NoArgs,24+ Short: "Start the Gordon server",
25+ Args: cobra.NoArgs,
20 Run: func(cmd *cobra.Command, args []string) {26 Run: func(cmd *cobra.Command, args []string) {
21 // handler.StartServer will use the value of port, which will be the default27 // handler.StartServer will use the value of port, which will be the default
22 // value if no flag is specified28 // value if no flag is specified
A internal/cli/cmd/update.go +371 -0
@@ -0,0 +1,371 @@
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
4+package cmd
5+
6+import (
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/fatih/color"
23+ "github.com/pocketbase/pocketbase/tools/archive"
24+ "github.com/spf13/cobra"
25+)
26+
27+type HttpClient interface {
28+ Do(req *http.Request) (*http.Response, error)
29+}
30+
31+type Config struct {
32+ Owner string
33+ Repo string
34+ ArchiveExecutable string
35+ Context context.Context
36+ HttpClient HttpClient
37+}
38+
39+var pluginInstance *plugin
40+
41+type plugin struct {
42+ app *cli.App
43+ currentVersion string
44+ config Config
45+}
46+
47+func NewUpdateCommand(a *cli.App) *cobra.Command {
48+
49+ pluginInstance = &plugin{
50+ app: a,
51+ currentVersion: a.Config.Build.BuildVersion,
52+ config: Config{
53+ Owner: "bnema",
54+ Repo: "gordon",
55+ ArchiveExecutable: "gordon",
56+ Context: context.Background(),
57+ HttpClient: http.DefaultClient,
58+ },
59+ }
60+
61+ if pluginInstance.app != a {
62+ panic("app instance is not the same")
63+ }
64+
65+ if a == nil {
66+ panic("app instance is nil")
67+ }
68+
69+ var withBackup bool
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 isMaybeRunningInDocker() {
79+ needConfirm = true
80+ color.Yellow("NB! 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+ return pluginInstance.update(withBackup)
97+ },
98+ }
99+
100+ command.PersistentFlags().BoolVar(
101+ &withBackup,
102+ "backup",
103+ true,
104+ "Creates a pb_data backup at the end of the update process",
105+ )
106+
107+ return command
108+}
109+
110+func (p *plugin) update(withBackup bool) error {
111+ color.Yellow("Fetching release information...")
112+
113+ latest, err := fetchLatestRelease(
114+ p.config.Context,
115+ p.config.HttpClient,
116+ p.config.Owner,
117+ p.config.Repo,
118+ )
119+ if err != nil {
120+ return err
121+ }
122+
123+ if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
124+ color.Green("You already have the latest PocketBase %s.", p.currentVersion)
125+ return nil
126+ }
127+
128+ suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
129+ if suffix == "" {
130+ return errors.New("unsupported platform")
131+ }
132+
133+ asset, err := latest.findAssetBySuffix(suffix)
134+ if err != nil {
135+ return err
136+ }
137+
138+ // temporary file is under common.Config.General.StorageDir
139+ releaseDir := filepath.Join(p.app.Config.General.StorageDir, "update")
140+ defer os.RemoveAll(releaseDir)
141+
142+ color.Yellow("Downloading %s...", asset.Name)
143+
144+ // download the release asset
145+ assetZip := filepath.Join(releaseDir, asset.Name)
146+ if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
147+ return err
148+ }
149+
150+ color.Yellow("Extracting %s...", asset.Name)
151+
152+ extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
153+ defer os.RemoveAll(extractDir)
154+
155+ if err := archive.Extract(assetZip, extractDir); err != nil {
156+ return err
157+ }
158+
159+ color.Yellow("Replacing the executable...")
160+
161+ oldExec, err := os.Executable()
162+ if err != nil {
163+ return err
164+ }
165+ renamedOldExec := oldExec + ".old"
166+ defer os.Remove(renamedOldExec)
167+
168+ newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
169+ if _, err := os.Stat(newExec); err != nil {
170+ // try again with an .exe extension
171+ newExec = newExec + ".exe"
172+ if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
173+ return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
174+ }
175+ }
176+
177+ // rename the current executable
178+ if err := os.Rename(oldExec, renamedOldExec); err != nil {
179+ return fmt.Errorf("Failed to rename the current executable: %w", err)
180+ }
181+
182+ tryToRevertExecChanges := func() {
183+ if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
184+ log.Println(revertErr)
185+ }
186+ }
187+
188+ // replace with the extracted binary
189+ if err := os.Rename(newExec, oldExec); err != nil {
190+ tryToRevertExecChanges()
191+ return fmt.Errorf("Failed replacing the executable: %w", err)
192+ }
193+
194+ color.HiBlack("---")
195+ color.Green("Update completed successfully! You can start the executable as usual.")
196+
197+ return nil
198+}
199+
200+func (r *release) findAssetBySuffix(suffix string) (*asset, error) {
201+ for _, asset := range r.Assets {
202+ if strings.HasSuffix(asset.Name, suffix) {
203+ return &asset, nil
204+ }
205+ }
206+
207+ return nil, fmt.Errorf("no asset found with suffix %q", suffix)
208+}
209+
210+type release struct {
211+ Tag string `json:"tag_name"`
212+ Assets []asset `json:"assets"`
213+}
214+
215+type asset struct {
216+ Name string `json:"name"`
217+ DownloadUrl string `json:"browser_download_url"`
218+}
219+
220+func fetchLatestRelease(
221+ ctx context.Context,
222+ client HttpClient,
223+ owner string,
224+ repo string,
225+) (*release, error) {
226+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
227+
228+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
229+ if err != nil {
230+ return nil, err
231+ }
232+
233+ res, err := client.Do(req)
234+ if err != nil {
235+ return nil, err
236+ }
237+ defer res.Body.Close()
238+
239+ rawBody, err := io.ReadAll(res.Body)
240+ if err != nil {
241+ return nil, err
242+ }
243+
244+ // http.Client doesn't treat non 2xx responses as error
245+ if res.StatusCode >= 400 {
246+ return nil, fmt.Errorf(
247+ "(%d) failed to fetch latest releases:\n%s",
248+ res.StatusCode,
249+ string(rawBody),
250+ )
251+ }
252+
253+ result := &release{}
254+ if err := json.Unmarshal(rawBody, result); err != nil {
255+ return nil, err
256+ }
257+
258+ return result, nil
259+}
260+
261+func downloadFile(
262+ ctx context.Context,
263+ client HttpClient,
264+ url string,
265+ destPath string,
266+) error {
267+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
268+ if err != nil {
269+ return err
270+ }
271+
272+ res, err := client.Do(req)
273+ if err != nil {
274+ return err
275+ }
276+ defer res.Body.Close()
277+
278+ // http.Client doesn't treat non 2xx responses as error
279+ if res.StatusCode >= 400 {
280+ return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
281+ }
282+
283+ // ensure that the dest parent dir(s) exist
284+ if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
285+ return err
286+ }
287+
288+ dest, err := os.Create(destPath)
289+ if err != nil {
290+ return err
291+ }
292+ defer dest.Close()
293+
294+ if _, err := io.Copy(dest, res.Body); err != nil {
295+ return err
296+ }
297+
298+ return nil
299+}
300+
301+// archives:
302+// - format: zip
303+// name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
304+
305+func archiveSuffix(goos, goarch string) string {
306+ // Define a map to hold the combinations of goos and goarch.
307+ var archMap = map[string]map[string]string{
308+ "linux": {
309+ "amd64": "_linux_amd64.zip",
310+ "arm64": "_linux_arm64.zip",
311+ "arm": "_linux_armv7.zip",
312+ },
313+ "darwin": {
314+ "amd64": "_darwin_amd64.zip",
315+ "arm64": "_darwin_arm64.zip",
316+ },
317+ "windows": {
318+ "amd64": "_windows_amd64.zip",
319+ "arm64": "_windows_arm64.zip",
320+ },
321+ }
322+
323+ // Look up goos then goarch.
324+ if arch, ok := archMap[goos]; ok {
325+ if suffix, ok := arch[goarch]; ok {
326+ return suffix
327+ }
328+ }
329+
330+ // Return a default value if no match is found.
331+ return ""
332+}
333+func compareVersions(a, b string) int {
334+ aSplit := strings.Split(a, ".")
335+ aTotal := len(aSplit)
336+
337+ bSplit := strings.Split(b, ".")
338+ bTotal := len(bSplit)
339+
340+ limit := aTotal
341+ if bTotal > aTotal {
342+ limit = bTotal
343+ }
344+
345+ for i := 0; i < limit; i++ {
346+ var x, y int
347+
348+ if i < aTotal {
349+ x, _ = strconv.Atoi(aSplit[i])
350+ }
351+
352+ if i < bTotal {
353+ y, _ = strconv.Atoi(bSplit[i])
354+ }
355+
356+ if x < y {
357+ return 1 // b is newer
358+ }
359+
360+ if x > y {
361+ return -1 // a is newer
362+ }
363+ }
364+
365+ return 0 // equal
366+}
367+
368+func isMaybeRunningInDocker() bool {
369+ _, err := os.Stat("/.iscontainer")
370+ return err == nil
371+}
@@ -0,0 +1,371 @@
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
4+package cmd
5+
6+import (
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/fatih/color"
23+ "github.com/pocketbase/pocketbase/tools/archive"
24+ "github.com/spf13/cobra"
25+)
26+
27+type HttpClient interface {
28+ Do(req *http.Request) (*http.Response, error)
29+}
30+
31+type Config struct {
32+ Owner string
33+ Repo string
34+ ArchiveExecutable string
35+ Context context.Context
36+ HttpClient HttpClient
37+}
38+
39+var pluginInstance *plugin
40+
41+type plugin struct {
42+ app *cli.App
43+ currentVersion string
44+ config Config
45+}
46+
47+func NewUpdateCommand(a *cli.App) *cobra.Command {
48+
49+ pluginInstance = &plugin{
50+ app: a,
51+ currentVersion: a.Config.Build.BuildVersion,
52+ config: Config{
53+ Owner: "bnema",
54+ Repo: "gordon",
55+ ArchiveExecutable: "gordon",
56+ Context: context.Background(),
57+ HttpClient: http.DefaultClient,
58+ },
59+ }
60+
61+ if pluginInstance.app != a {
62+ panic("app instance is not the same")
63+ }
64+
65+ if a == nil {
66+ panic("app instance is nil")
67+ }
68+
69+ var withBackup bool
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 isMaybeRunningInDocker() {
79+ needConfirm = true
80+ color.Yellow("NB! 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+ return pluginInstance.update(withBackup)
97+ },
98+ }
99+
100+ command.PersistentFlags().BoolVar(
101+ &withBackup,
102+ "backup",
103+ true,
104+ "Creates a pb_data backup at the end of the update process",
105+ )
106+
107+ return command
108+}
109+
110+func (p *plugin) update(withBackup bool) error {
111+ color.Yellow("Fetching release information...")
112+
113+ latest, err := fetchLatestRelease(
114+ p.config.Context,
115+ p.config.HttpClient,
116+ p.config.Owner,
117+ p.config.Repo,
118+ )
119+ if err != nil {
120+ return err
121+ }
122+
123+ if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
124+ color.Green("You already have the latest PocketBase %s.", p.currentVersion)
125+ return nil
126+ }
127+
128+ suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
129+ if suffix == "" {
130+ return errors.New("unsupported platform")
131+ }
132+
133+ asset, err := latest.findAssetBySuffix(suffix)
134+ if err != nil {
135+ return err
136+ }
137+
138+ // temporary file is under common.Config.General.StorageDir
139+ releaseDir := filepath.Join(p.app.Config.General.StorageDir, "update")
140+ defer os.RemoveAll(releaseDir)
141+
142+ color.Yellow("Downloading %s...", asset.Name)
143+
144+ // download the release asset
145+ assetZip := filepath.Join(releaseDir, asset.Name)
146+ if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
147+ return err
148+ }
149+
150+ color.Yellow("Extracting %s...", asset.Name)
151+
152+ extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
153+ defer os.RemoveAll(extractDir)
154+
155+ if err := archive.Extract(assetZip, extractDir); err != nil {
156+ return err
157+ }
158+
159+ color.Yellow("Replacing the executable...")
160+
161+ oldExec, err := os.Executable()
162+ if err != nil {
163+ return err
164+ }
165+ renamedOldExec := oldExec + ".old"
166+ defer os.Remove(renamedOldExec)
167+
168+ newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
169+ if _, err := os.Stat(newExec); err != nil {
170+ // try again with an .exe extension
171+ newExec = newExec + ".exe"
172+ if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
173+ return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
174+ }
175+ }
176+
177+ // rename the current executable
178+ if err := os.Rename(oldExec, renamedOldExec); err != nil {
179+ return fmt.Errorf("Failed to rename the current executable: %w", err)
180+ }
181+
182+ tryToRevertExecChanges := func() {
183+ if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
184+ log.Println(revertErr)
185+ }
186+ }
187+
188+ // replace with the extracted binary
189+ if err := os.Rename(newExec, oldExec); err != nil {
190+ tryToRevertExecChanges()
191+ return fmt.Errorf("Failed replacing the executable: %w", err)
192+ }
193+
194+ color.HiBlack("---")
195+ color.Green("Update completed successfully! You can start the executable as usual.")
196+
197+ return nil
198+}
199+
200+func (r *release) findAssetBySuffix(suffix string) (*asset, error) {
201+ for _, asset := range r.Assets {
202+ if strings.HasSuffix(asset.Name, suffix) {
203+ return &asset, nil
204+ }
205+ }
206+
207+ return nil, fmt.Errorf("no asset found with suffix %q", suffix)
208+}
209+
210+type release struct {
211+ Tag string `json:"tag_name"`
212+ Assets []asset `json:"assets"`
213+}
214+
215+type asset struct {
216+ Name string `json:"name"`
217+ DownloadUrl string `json:"browser_download_url"`
218+}
219+
220+func fetchLatestRelease(
221+ ctx context.Context,
222+ client HttpClient,
223+ owner string,
224+ repo string,
225+) (*release, error) {
226+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
227+
228+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
229+ if err != nil {
230+ return nil, err
231+ }
232+
233+ res, err := client.Do(req)
234+ if err != nil {
235+ return nil, err
236+ }
237+ defer res.Body.Close()
238+
239+ rawBody, err := io.ReadAll(res.Body)
240+ if err != nil {
241+ return nil, err
242+ }
243+
244+ // http.Client doesn't treat non 2xx responses as error
245+ if res.StatusCode >= 400 {
246+ return nil, fmt.Errorf(
247+ "(%d) failed to fetch latest releases:\n%s",
248+ res.StatusCode,
249+ string(rawBody),
250+ )
251+ }
252+
253+ result := &release{}
254+ if err := json.Unmarshal(rawBody, result); err != nil {
255+ return nil, err
256+ }
257+
258+ return result, nil
259+}
260+
261+func downloadFile(
262+ ctx context.Context,
263+ client HttpClient,
264+ url string,
265+ destPath string,
266+) error {
267+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
268+ if err != nil {
269+ return err
270+ }
271+
272+ res, err := client.Do(req)
273+ if err != nil {
274+ return err
275+ }
276+ defer res.Body.Close()
277+
278+ // http.Client doesn't treat non 2xx responses as error
279+ if res.StatusCode >= 400 {
280+ return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
281+ }
282+
283+ // ensure that the dest parent dir(s) exist
284+ if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
285+ return err
286+ }
287+
288+ dest, err := os.Create(destPath)
289+ if err != nil {
290+ return err
291+ }
292+ defer dest.Close()
293+
294+ if _, err := io.Copy(dest, res.Body); err != nil {
295+ return err
296+ }
297+
298+ return nil
299+}
300+
301+// archives:
302+// - format: zip
303+// name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
304+
305+func archiveSuffix(goos, goarch string) string {
306+ // Define a map to hold the combinations of goos and goarch.
307+ var archMap = map[string]map[string]string{
308+ "linux": {
309+ "amd64": "_linux_amd64.zip",
310+ "arm64": "_linux_arm64.zip",
311+ "arm": "_linux_armv7.zip",
312+ },
313+ "darwin": {
314+ "amd64": "_darwin_amd64.zip",
315+ "arm64": "_darwin_arm64.zip",
316+ },
317+ "windows": {
318+ "amd64": "_windows_amd64.zip",
319+ "arm64": "_windows_arm64.zip",
320+ },
321+ }
322+
323+ // Look up goos then goarch.
324+ if arch, ok := archMap[goos]; ok {
325+ if suffix, ok := arch[goarch]; ok {
326+ return suffix
327+ }
328+ }
329+
330+ // Return a default value if no match is found.
331+ return ""
332+}
333+func compareVersions(a, b string) int {
334+ aSplit := strings.Split(a, ".")
335+ aTotal := len(aSplit)
336+
337+ bSplit := strings.Split(b, ".")
338+ bTotal := len(bSplit)
339+
340+ limit := aTotal
341+ if bTotal > aTotal {
342+ limit = bTotal
343+ }
344+
345+ for i := 0; i < limit; i++ {
346+ var x, y int
347+
348+ if i < aTotal {
349+ x, _ = strconv.Atoi(aSplit[i])
350+ }
351+
352+ if i < bTotal {
353+ y, _ = strconv.Atoi(bSplit[i])
354+ }
355+
356+ if x < y {
357+ return 1 // b is newer
358+ }
359+
360+ if x > y {
361+ return -1 // a is newer
362+ }
363+ }
364+
365+ return 0 // equal
366+}
367+
368+func isMaybeRunningInDocker() bool {
369+ _, err := os.Stat("/.iscontainer")
370+ return err == nil
371+}
M internal/common/initconfig.go +5 -0
@@ -174,3 +174,8 @@ func (config *BuildConfig) GetRunEnv() string {
174174 func (config *Config) GetVersion() string {
175175 return config.Build.BuildVersion
176176 }
177+
178+// get storage dir
179+func (config *Config) GetStorageDir() string {
180+ return config.General.StorageDir
181+}
@@ -174,3 +174,8 @@ func (config *BuildConfig) GetRunEnv() string {
174 func (config *Config) GetVersion() string {174 func (config *Config) GetVersion() string {
175 return config.Build.BuildVersion175 return config.Build.BuildVersion
176 }176 }
177+
178+// get storage dir
179+func (config *Config) GetStorageDir() string {
180+ return config.General.StorageDir
181+}
M internal/httpserve/handler/clientendpoins.go +0 -5
@@ -15,11 +15,6 @@ import (
1515 "github.com/labstack/echo/v4"
1616 )
1717
18-// Handle GET on /api/hello endpoint
19-func GetHello(c echo.Context, a *server.App) error {
20- return c.JSON(http.StatusOK, "Hello, World!")
21-}
22-
2318 type InfoResponse struct {
2419 Uptime string `json:"uptime"`
2520 Version string `json:"version"`
@@ -15,11 +15,6 @@ import (
15 "github.com/labstack/echo/v4"15 "github.com/labstack/echo/v4"
16 )16 )
17 17
18-// Handle GET on /api/hello endpoint
19-func GetHello(c echo.Context, a *server.App) error {
20- return c.JSON(http.StatusOK, "Hello, World!")
21-}
22-
23 type InfoResponse struct {18 type InfoResponse struct {
24 Uptime string `json:"uptime"`19 Uptime string `json:"uptime"`
25 Version string `json:"version"`20 Version string `json:"version"`
M internal/httpserve/middleware/requiretoken.go +2 -2
@@ -23,7 +23,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc {
2323 func validateToken(c echo.Context, a *server.App) error {
2424 token := c.Request().Header.Get("Authorization")
2525 if token == "" {
26- return echo.ErrUnauthorized
26+ return c.JSON(401, "Unauthorized")
2727 }
2828 fmt.Println(token)
2929 token = strings.Replace(token, "Bearer ", "", 1)
@@ -33,7 +33,7 @@ func validateToken(c echo.Context, a *server.App) error {
3333 }
3434
3535 if token != configToken {
36- return echo.ErrUnauthorized
36+ return c.JSON(401, "Unauthorized")
3737 }
3838
3939 return nil
@@ -23,7 +23,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc {
23 func validateToken(c echo.Context, a *server.App) error {23 func validateToken(c echo.Context, a *server.App) error {
24 token := c.Request().Header.Get("Authorization")24 token := c.Request().Header.Get("Authorization")
25 if token == "" {25 if token == "" {
26- return echo.ErrUnauthorized26+ return c.JSON(401, "Unauthorized")
27 }27 }
28 fmt.Println(token)28 fmt.Println(token)
29 token = strings.Replace(token, "Bearer ", "", 1)29 token = strings.Replace(token, "Bearer ", "", 1)
@@ -33,7 +33,7 @@ func validateToken(c echo.Context, a *server.App) error {
33 }33 }
34 34
35 if token != configToken {35 if token != configToken {
36- return echo.ErrUnauthorized36+ return c.JSON(401, "Unauthorized")
37 }37 }
38 38
39 return nil39 return nil
M internal/httpserve/router.go +0 -3
@@ -51,9 +51,6 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
5151 func bindAPIEndpoints(e *echo.Echo, a *server.App) {
5252 apiGroup := e.Group("/api")
5353 apiGroup.Use(middleware.RequireToken(a))
54- apiGroup.GET("/hello", func(c echo.Context) error {
55- return handler.GetHello(c, a)
56- })
5754 apiGroup.GET("/ping", func(c echo.Context) error {
5855 return handler.GetInfos(c, a)
5956 })
@@ -51,9 +51,6 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
51 func bindAPIEndpoints(e *echo.Echo, a *server.App) {51 func bindAPIEndpoints(e *echo.Echo, a *server.App) {
52 apiGroup := e.Group("/api")52 apiGroup := e.Group("/api")
53 apiGroup.Use(middleware.RequireToken(a))53 apiGroup.Use(middleware.RequireToken(a))
54- apiGroup.GET("/hello", func(c echo.Context) error {
55- return handler.GetHello(c, a)
56- })
57 apiGroup.GET("/ping", func(c echo.Context) error {54 apiGroup.GET("/ping", func(c echo.Context) error {
58 return handler.GetInfos(c, a)55 return handler.GetInfos(c, a)
59 })56 })

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