2023-05-17 21:14:12 +03:00
// Package ghupdate implements a new command to selfupdate the current
// PocketBase executable with the latest GitHub release.
2023-06-08 17:59:08 +03:00
//
// Example usage:
//
// ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
2023-05-17 21:14:12 +03:00
package ghupdate
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
2023-11-26 13:33:17 +02:00
"log/slog"
2023-05-17 21:14:12 +03:00
"net/http"
"os"
"path/filepath"
"runtime"
2023-05-23 11:35:46 +03:00
"strconv"
2023-05-21 13:00:12 +03:00
"strings"
2023-05-17 21:14:12 +03:00
2023-07-26 13:18:59 +03:00
"github.com/AlecAivazis/survey/v2"
2023-05-17 21:14:12 +03:00
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/archive"
"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 )
}
2023-06-08 17:59:08 +03:00
// Config defines the config options of the ghupdate plugin.
2023-05-17 21:14:12 +03:00
//
2023-06-08 17:59:08 +03:00
// NB! This plugin is considered experimental and its config options may change in the future.
type Config struct {
2023-05-17 21:14:12 +03:00
// Owner specifies the account owner of the repository (default to "pocketbase").
Owner string
// Repo specifies the name of the repository (default to "pocketbase").
Repo string
2023-05-28 21:57:12 +03:00
// ArchiveExecutable specifies the name of the executable file in the release archive
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
2023-05-17 21:14:12 +03:00
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 ghupdate plugin to the provided app instance
// and panic if it fails.
2023-06-08 17:59:08 +03:00
func MustRegister ( app core . App , rootCmd * cobra . Command , config Config ) {
if err := Register ( app , rootCmd , config ) ; err != nil {
2023-05-17 21:14:12 +03:00
panic ( err )
}
}
// Register registers the ghupdate plugin to the provided app instance.
2023-06-08 17:59:08 +03:00
func Register ( app core . App , rootCmd * cobra . Command , config Config ) error {
2023-05-17 21:14:12 +03:00
p := & plugin {
app : app ,
currentVersion : rootCmd . Version ,
2023-06-08 17:59:08 +03:00
config : config ,
2023-05-17 21:14:12 +03:00
}
2023-06-08 17:59:08 +03:00
if p . config . Owner == "" {
p . config . Owner = "pocketbase"
2023-05-17 21:14:12 +03:00
}
2023-06-08 17:59:08 +03:00
if p . config . Repo == "" {
p . config . Repo = "pocketbase"
2023-05-17 21:14:12 +03:00
}
2023-06-08 17:59:08 +03:00
if p . config . ArchiveExecutable == "" {
p . config . ArchiveExecutable = "pocketbase"
2023-05-17 21:14:12 +03:00
}
2023-06-08 17:59:08 +03:00
if p . config . HttpClient == nil {
p . config . HttpClient = http . DefaultClient
2023-05-17 21:14:12 +03:00
}
2023-06-08 17:59:08 +03:00
if p . config . Context == nil {
p . config . Context = context . Background ( )
2023-05-17 21:14:12 +03:00
}
rootCmd . AddCommand ( p . updateCmd ( ) )
return nil
}
type plugin struct {
app core . App
2023-06-08 17:59:08 +03:00
config Config
2024-02-02 12:48:37 +02:00
currentVersion string
2023-05-17 21:14:12 +03:00
}
func ( p * plugin ) updateCmd ( ) * cobra . Command {
var withBackup bool
command := & cobra . Command {
2023-12-03 13:44:30 +02:00
Use : "update" ,
2024-01-13 11:21:55 +02:00
Short : "Automatically updates the current app executable with the latest available version" ,
2023-12-03 13:44:30 +02:00
SilenceUsage : true ,
2023-05-17 21:14:12 +03:00
RunE : func ( command * cobra . Command , args [ ] string ) error {
2023-07-26 13:18:59 +03:00
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 := false
prompt := & survey . Confirm {
Message : "Do you want to proceed with the update?" ,
}
survey . AskOne ( prompt , & confirm )
if ! confirm {
fmt . Println ( "The command has been cancelled." )
return nil
}
}
2023-05-17 21:14:12 +03:00
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 := fetchLatestRelease (
2023-06-08 17:59:08 +03:00
p . config . Context ,
p . config . HttpClient ,
p . config . Owner ,
p . config . Repo ,
2023-05-17 21:14:12 +03:00
)
if err != nil {
return err
}
2023-05-23 11:35:46 +03:00
if compareVersions ( strings . TrimPrefix ( p . currentVersion , "v" ) , strings . TrimPrefix ( latest . Tag , "v" ) ) <= 0 {
2024-01-13 11:21:55 +02:00
color . Green ( "You already have the latest version %s." , p . currentVersion )
2023-05-17 21:14:12 +03:00
return nil
}
suffix := archiveSuffix ( runtime . GOOS , runtime . GOARCH )
if suffix == "" {
return errors . New ( "unsupported platform" )
}
asset , err := latest . findAssetBySuffix ( suffix )
if err != nil {
return err
}
2023-05-28 21:57:12 +03:00
releaseDir := filepath . Join ( p . app . DataDir ( ) , core . LocalTempDirName )
2023-05-17 21:14:12 +03:00
defer os . RemoveAll ( releaseDir )
color . Yellow ( "Downloading %s..." , asset . Name )
// download the release asset
2023-05-28 21:57:12 +03:00
assetZip := filepath . Join ( releaseDir , asset . Name )
2023-06-08 17:59:08 +03:00
if err := downloadFile ( p . config . Context , p . config . HttpClient , asset . DownloadUrl , assetZip ) ; err != nil {
2023-05-17 21:14:12 +03:00
return err
}
color . Yellow ( "Extracting %s..." , asset . Name )
2023-05-28 21:57:12 +03:00
extractDir := filepath . Join ( releaseDir , "extracted_" + asset . Name )
2023-05-17 21:14:12 +03:00
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 )
2023-06-08 17:59:08 +03:00
newExec := filepath . Join ( extractDir , p . config . ArchiveExecutable )
2023-05-28 21:57:12 +03:00
if _ , err := os . Stat ( newExec ) ; err != nil {
// try again with an .exe extension
newExec = newExec + ".exe"
2023-05-28 22:00:48 +03:00
if _ , fallbackErr := os . Stat ( newExec ) ; fallbackErr != nil {
2023-05-29 17:01:04 +03:00
return fmt . Errorf ( "The executable in the extracted path is missing or it is inaccessible: %v, %v" , err , fallbackErr )
2023-05-28 21:57:12 +03:00
}
}
2023-05-17 21:14:12 +03:00
// 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 ( ) {
2023-11-26 13:33:17 +02:00
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 ( ) ) ,
)
2023-05-17 21:14:12 +03:00
}
}
// replace with the extracted binary
if err := os . Rename ( newExec , oldExec ) ; err != nil {
tryToRevertExecChanges ( )
return fmt . Errorf ( "Failed replacing the executable: %w" , err )
}
if withBackup {
color . Yellow ( "Creating pb_data backup..." )
backupName := fmt . Sprintf ( "@update_%s.zip" , latest . Tag )
2023-06-08 17:59:08 +03:00
if err := p . app . CreateBackup ( p . config . Context , backupName ) ; err != nil {
2023-05-17 21:14:12 +03:00
tryToRevertExecChanges ( )
return err
}
}
color . HiBlack ( "---" )
2023-07-30 13:40:22 +03:00
color . Green ( "Update completed successfully! You can start the executable as usual." )
2023-05-17 21:14:12 +03:00
2023-11-06 11:19:12 +02:00
// print the release notes
if latest . Body != "" {
fmt . Print ( "\n" )
color . Cyan ( "Here is a list with some of the %s changes:" , latest . Tag )
// remove the update command note to avoid "stuttering"
2024-09-29 19:23:19 +03:00
// (@todo consider moving to a config option)
2024-02-02 12:48:37 +02:00
releaseNotes := strings . TrimSpace ( strings . Replace ( latest . Body , "> _To update the prebuilt executable you can run `./" + p . config . ArchiveExecutable + " update`._" , "" , 1 ) )
2023-11-06 11:19:12 +02:00
color . Cyan ( releaseNotes )
fmt . Print ( "\n" )
}
2023-05-17 21:14:12 +03:00
return nil
}
func fetchLatestRelease (
ctx context . Context ,
client HttpClient ,
owner string ,
repo string ,
) ( * release , error ) {
url := fmt . Sprintf ( "https://api.github.com/repos/%s/%s/releases/latest" , owner , repo )
req , err := http . NewRequestWithContext ( ctx , "GET" , url , nil )
if err != nil {
return nil , err
}
res , err := client . 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 := & release { }
if err := json . Unmarshal ( rawBody , result ) ; err != nil {
return nil , err
}
return result , 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 ""
}
2023-05-23 11:35:46 +03:00
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
}
2023-07-26 13:18:59 +03:00
// 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
}