added BugStage update command

This commit is contained in:
Gani Georgiev committed 2026-10-22 12:59:10 +03:00
1 parent 240a056144
commit b5eb256632
7 files changed
+603 -5

No files matched your search

+10 -2
View File
@@ -1,10 +1,18 @@
## v0.40.6 (WIP)
- @todo Replaced the GitHub update command with update from the new host.
- Replaced the GitHub update command with update from the new host in relation to [#7841](https://github.com/pocketbase/pocketbase/discussions/7841).
Note that this will be most likely the last release here on GitHub. The next time when you run update, new releases will be fetched from `https://ganigeorgiev.com` _(there is nothing there yet)_.
- Additional fileblob bucket escape checks for just in case if the filesystem abstraction is used as standalone without the PocketBase validations.
- Updated `modernc.org/sqlite` to 1.61.0 _([SQLite 3.54.1](https://sqlite.org/src/timeline?from=version-3.54.0&to=version-3.54.1&y=ci))_.
- Other minor fixes (superuser IPs description grammar fix, additional fileblob bucket escape checks for just in case if the filesystem is used as standalone without the PocketBase validations, bumped `golang.org/x/` dependencies, bumped min Go GitHub action version to 1.27.3, rotating btn loader icon alignment fix, slight dark background correction in attempt to minimize the color profile differences between Chrome and Firefox etc.)
- Bumped the min Go GitHub action version to 1.27.3 as it includes a [security fix](https://groups.google.com/g/golang-announce/c/66pojueaQek) related to the recent eMudhra CA compromise.
_I didn't rush a fix 2 days ago as the impact for most PocketBase servers is not that serious and requires to already have a MiTM, but nonetheless it is recommended to update. If you can, check also for OS/Docker image updates to make sure that you have the latest `ca-certificates` bundle._
- Bumped `golang.org/x/` dependencies ([#7865](https://github.com/pocketbase/pocketbase/discussions/7865)).
- Other minor UI fixes (superuser IPs description grammar fix, rotating btn loader icon alignment fix, slight dark background correction in attempt to minimize the color profile differences between Chrome and Firefox, etc.).
## v0.40.5
+7 -3
View File
@@ -9,9 +9,9 @@ import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/ghupdate"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/plugins/update"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/osutils"
)
@@ -100,8 +100,12 @@ func main() {
Dir: migrationsDir,
})
// GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
// update command
update.MustRegister(app, app.RootCmd, update.Config{
BaseURL: "https://ganigeorgiev.com",
Project: "pocketbase",
Repository: "pocketbase",
})
// static route to serves files from the provided public dir
// (if publicDir exists and the route path is not already defined)
+3
View File
@@ -1,6 +1,9 @@
// Package ghupdate implements a new command to selfupdate the current
// PocketBase executable with the latest GitHub release.
//
// This is kept only for backward-compatibility and it is replaced by
// the equivalent `update` BugStage plugin.
//
// Example usage:
//
// ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
+41
View File
@@ -0,0 +1,41 @@
package update
import (
"fmt"
"net/url"
"strings"
)
type release struct {
Id string `json:"id"`
CollectionName string `json:"collectionName"`
Channel string `json:"channel"`
Tag string `json:"tag"`
Content string `json:"content"`
Created string `json:"created"`
Updated string `json:"updated"`
Files []string `json:"files"`
}
// findFileBySuffix returns the first available asset containing the specified suffix.
func (r *release) findFileBySuffix(suffix string) (string, error) {
if suffix != "" {
for _, filename := range r.Files {
if strings.HasSuffix(filename, suffix) {
return filename, nil
}
}
}
return "", fmt.Errorf("failed to find release file ending with %q", suffix)
}
// buildFilePath constructs the URL path to the release file.
func (r *release) buildFilePath(filename string) string {
return fmt.Sprintf(
"/api/files/%s/%s/%s",
url.PathEscape(r.CollectionName),
url.PathEscape(r.Id),
url.PathEscape(filename),
)
}
+38
View File
@@ -0,0 +1,38 @@
package update
import "testing"
func TestReleaseFindFileBySuffix(t *testing.T) {
r := release{
Files: []string{
"test1.zip",
"test2.zip",
"test22.zip",
"test3.zip",
},
}
filename, err := r.findFileBySuffix("2.zip")
if err != nil {
t.Fatalf("Expected nil, got err: %v", err)
}
expected := "test2.zip"
if filename != expected {
t.Fatalf("Expected file %q, got %q", expected, filename)
}
}
func TestReleaseBuildFilePath(t *testing.T) {
r := release{
Id: "test id",
CollectionName: "test collection",
}
result := r.buildFilePath("test file")
expected := "/api/files/test%20collection/test%20id/test%20file"
if result != expected {
t.Fatalf("Expected path %q, got %q", expected, result)
}
}
+466
View File
@@ -0,0 +1,466 @@
// Package update implements a new command to replace the current
// PocketBase executable with the one from the latest BugStage releases.
//
// Note that it expects the fetched release to have file(s) in the following formats:
// - "*_linux_amd64.zip"
// - "*_linux_arm64.zip"
// - "*_linux_armv7.zip"
// - "*_darwin_amd64.zip"
// - "*_darwin_arm64.zip"
// - "*_windows_amd64.zip"
// - "*_windows_arm64.zip"
//
// The appropriate file is auto downloaded based on the user platform.
// If there is no file matching the user platform then an error is returned.
//
// Example plugin registration:
//
// update.MustRegister(app, app.RootCmd, update.Config{
// BaseURL: "https://ganigeorgiev.com",
// Project: "pocketbase",
// Repository: "pocketbase",
// })
package update
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cobra"
)
// HttpClient is a base HTTP client interface (usually used for test purposes).
type HttpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// Config defines the config options of the update plugin.
type Config struct {
// BaseURL is the base URL of the BugStage host (or similar compatible)
// used to fetch the latest releases information.
//
// This field is required!
BaseURL string
// Project specifies the project name of the repository.
//
// This field is required!
Project string
// Repository specifies the name of the repository.
//
// This field is required!
Repository string
// Channel specifies the name of the release channel to check.
//
// If not set uses the system "default" channel.
Channel string
// ArchiveExecutable specifies the name of the executable file in the release archive
// (default to the same value as in [Config.Repository]; an additional ".exe" check is also performed as a fallback).
ArchiveExecutable string
// Optional context to use when fetching and downloading the latest release.
Context context.Context
// The HTTP client to use when fetching and downloading the latest release.
// Defaults to [http.DefaultClient].
HttpClient HttpClient
}
// MustRegister registers the update plugin to the provided app instance
// and panic if it fails.
func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
if err := Register(app, rootCmd, config); err != nil {
panic(err)
}
}
// Register registers the update plugin to the provided app instance.
func Register(app core.App, rootCmd *cobra.Command, config Config) error {
if config.BaseURL == "" {
return errors.New("update.Config.BaseURL is required and needs to point to a BugStage instance")
}
if config.Project == "" {
return errors.New("update.Config.Project is required")
}
if config.Repository == "" {
return errors.New("update.Config.Repository is required")
}
p := &plugin{
app: app,
currentVersion: rootCmd.Version,
config: config,
}
p.config.BaseURL = strings.TrimRight(p.config.BaseURL, "/")
if p.config.ArchiveExecutable == "" {
p.config.ArchiveExecutable = p.config.Repository
}
if p.config.Channel == "" {
p.config.Channel = "default"
}
if p.config.HttpClient == nil {
p.config.HttpClient = http.DefaultClient
}
if p.config.Context == nil {
p.config.Context = context.Background()
}
rootCmd.AddCommand(p.updateCmd())
return nil
}
type plugin struct {
app core.App
config Config
currentVersion string
}
func (p *plugin) updateCmd() *cobra.Command {
var withBackup bool
command := &cobra.Command{
Use: "update",
Short: "Automatically updates the current app executable with the latest available version",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
var needConfirm bool
if isMaybeRunningInDocker() {
needConfirm = true
color.Yellow("NB! It seems that you are in a Docker container.")
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.")
} else if isMaybeRunningInNixOS() {
needConfirm = true
color.Yellow("NB! It seems that you are in a NixOS.")
color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
}
if needConfirm {
confirm := osutils.YesNoPrompt("Do you want to proceed with the update?", false)
if !confirm {
fmt.Println("The command has been cancelled.")
return nil
}
}
return p.update(withBackup)
},
}
command.PersistentFlags().BoolVar(
&withBackup,
"backup",
true,
"Creates a pb_data backup at the end of the update process",
)
return command
}
func (p *plugin) update(withBackup bool) error {
color.Yellow("Fetching release information...")
latest, err := p.fetchLatestRelease()
if err != nil {
return err
}
if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
color.Green("You already have the latest version %s.", p.currentVersion)
return nil
}
suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
if suffix == "" {
return errors.New("unsupported platform")
}
filename, err := latest.findFileBySuffix(suffix)
if err != nil {
return err
}
fileURL := p.config.BaseURL + latest.buildFilePath(filename)
releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
defer os.RemoveAll(releaseDir)
color.Yellow("Downloading %s...", filename)
// download the release asset
assetZip := filepath.Join(releaseDir, filename)
if err := downloadFile(p.config.Context, p.config.HttpClient, fileURL, assetZip); err != nil {
return err
}
color.Yellow("Extracting %s...", filename)
extractDir := filepath.Join(releaseDir, "extracted_"+filename)
defer os.RemoveAll(extractDir)
if err := archive.Extract(assetZip, extractDir); err != nil {
return err
}
color.Yellow("Replacing the executable...")
oldExec, err := os.Executable()
if err != nil {
return err
}
renamedOldExec := oldExec + ".old"
defer os.Remove(renamedOldExec)
newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
if _, err := os.Stat(newExec); err != nil {
// try again with an .exe extension
newExec = newExec + ".exe"
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
}
}
// rename the current executable
if err := os.Rename(oldExec, renamedOldExec); err != nil {
return fmt.Errorf("failed to rename the current executable: %w", err)
}
tryToRevertExecChanges := func() {
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
p.app.Logger().Debug(
"Failed to revert executable",
slog.String("old", renamedOldExec),
slog.String("new", oldExec),
slog.String("error", revertErr.Error()),
)
}
}
// replace with the extracted binary
if err := os.Rename(newExec, oldExec); err != nil {
tryToRevertExecChanges()
return fmt.Errorf("failed replacing the executable: %w", err)
}
// backup existing pb_data
if withBackup {
color.Yellow("Creating pb_data backup...")
backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
tryToRevertExecChanges()
return err
}
}
// print the release notes (if any)
if latest.Content != "" {
fmt.Print("\n")
color.Cyan("%s release notes:", latest.Tag)
color.Cyan(strings.TrimSpace(latest.Content))
fmt.Print("\n")
}
color.HiBlack("---")
fmt.Print("\n")
color.Green("Update completed successfully! You can start the executable as usual.")
fmt.Print("\n")
return nil
}
func (p *plugin) fetchLatestRelease() (*release, error) {
queryParams := url.Values{}
queryParams.Set("perPage", "1")
queryParams.Set("skipTotal", "1")
queryParams.Set("sort", "-semver")
queryParams.Set("filter", fmt.Sprintf(
"channel.name=%q && channel.repository.name=%q && channel.repository.project.name=%q",
p.config.Channel,
p.config.Repository,
p.config.Project,
))
releaseURL := fmt.Sprintf("%s/api/collections/releases/records?%s", p.config.BaseURL, queryParams.Encode())
req, err := http.NewRequestWithContext(p.config.Context, "GET", releaseURL, nil)
if err != nil {
return nil, err
}
res, err := p.config.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
rawBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// http.Client doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return nil, fmt.Errorf(
"(%d) failed to fetch latest releases:\n%s",
res.StatusCode,
string(rawBody),
)
}
result := struct {
Items []*release `json:"items"`
}{}
if err := json.Unmarshal(rawBody, &result); err != nil {
return nil, err
}
if len(result.Items) == 0 {
return nil, errors.New("no releases found")
}
return result.Items[0], nil
}
func downloadFile(
ctx context.Context,
client HttpClient,
url string,
destPath string,
) error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
// http.Client doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
}
// ensure that the dest parent dir(s) exist
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
return err
}
dest, err := os.Create(destPath)
if err != nil {
return err
}
defer dest.Close()
if _, err := io.Copy(dest, res.Body); err != nil {
return err
}
return nil
}
func archiveSuffix(goos, goarch string) string {
switch goos {
case "linux":
switch goarch {
case "amd64":
return "_linux_amd64.zip"
case "arm64":
return "_linux_arm64.zip"
case "arm":
return "_linux_armv7.zip"
}
case "darwin":
switch goarch {
case "amd64":
return "_darwin_amd64.zip"
case "arm64":
return "_darwin_arm64.zip"
}
case "windows":
switch goarch {
case "amd64":
return "_windows_amd64.zip"
case "arm64":
return "_windows_arm64.zip"
}
}
return ""
}
func compareVersions(a, b string) int {
aSplit := strings.Split(a, ".")
aTotal := len(aSplit)
bSplit := strings.Split(b, ".")
bTotal := len(bSplit)
limit := aTotal
if bTotal > aTotal {
limit = bTotal
}
for i := 0; i < limit; i++ {
var x, y int
if i < aTotal {
x, _ = strconv.Atoi(aSplit[i])
}
if i < bTotal {
y, _ = strconv.Atoi(bSplit[i])
}
if x < y {
return 1 // b is newer
}
if x > y {
return -1 // a is newer
}
}
return 0 // equal
}
// note: not completely reliable as it may not work on all platforms
// but should at least provide a warning for the most common use cases
func isMaybeRunningInDocker() bool {
_, err := os.Stat("/.dockerenv")
return err == nil
}
// note: untested
func isMaybeRunningInNixOS() bool {
_, err := os.Stat("/etc/NIXOS")
return err == nil
}
+38
View File
@@ -0,0 +1,38 @@
package update
import "testing"
func TestCompareVersions(t *testing.T) {
scenarios := []struct {
a string
b string
expected int
}{
{"", "", 0},
{"0", "", 0},
{"1", "1.0.0", 0},
{"1.1", "1.1.0", 0},
{"1.1", "1.1.1", 1},
{"1.1", "1.0.1", -1},
{"1.0", "1.0.1", 1},
{"1.10", "1.9", -1},
{"1.2", "1.12", 1},
{"3.2", "1.6", -1},
{"0.0.2", "0.0.1", -1},
{"0.16.2", "0.17.0", 1},
{"1.15.0", "0.16.1", -1},
{"1.2.9", "1.2.10", 1},
{"3.2", "4.0", 1},
{"3.2.4", "3.2.3", -1},
}
for _, s := range scenarios {
t.Run(s.a+"VS"+s.b, func(t *testing.T) {
result := compareVersions(s.a, s.b)
if result != s.expected {
t.Fatalf("Expected %q vs %q to result in %d, got %d", s.a, s.b, s.expected, result)
}
})
}
}