Files
base/plugins/ghupdate/ghupdate.go
zeekay e7df8c856e base: complete pocketbase→base rename; Base = pure IAM client
Rename / namespace:
- Finish github.com/pocketbase/* → github.com/hanzoai/base (+ /dbx) sweep
  across all Go sources; restore the renamed root launcher (Base, base.go).
- Drop the upstream-only surfaces ours doesn't ship (admin ui/, the
  password/OTP/oauth2/impersonate auth files, extensions) re-added by the
  upstream merge.
- Decomplect the on-disk layout to one base_/ namespace: data/, migrations/,
  hooks/, types.d.ts are siblings under base_/ (default data dir
  base_/data). Drop the hz_ prefix everywhere; collapse migratecmd's
  identical JS/Go branches to one path.
- Remove the accidentally-committed hz_data/ dev dir (db + iam.key) and
  gitignore the runtime namespace dirs.

Deps / build:
- kmsclient now imported from the light github.com/hanzoai/kms/sdk/go module
  (the root kms module zip exceeds Go's download limit); cloud/pkg/base
  Mount removed (lives in the fused daemon).
- replicate v0.4.0 → v0.8.0; metric.Counter.Get() (no prometheus dto);
  network/metrics.go import fix; auth gitlab/github tidy.

Tests / behavior:
- Wire registerNotifyWatcherHooks; debounce notify files past the kqueue
  rescan window so cross-instance settings/collections reload fires.
- migratecmd: exclude createdAt/updatedAt (not created/updated); regenerate
  Go+JS automigration goldens from canonical output.
- Convert the two stale archive red-probes to assert the landed defenses
  (backlog cap bounds memory; forged-segment signature is rejected).

IAM-native (Base = pure IAM client):
- Rip the ~1k-line home-grown embedded IAM (iam_embedded*.go) and the
  iam-user CLI. Base no longer reinvents identity as _iam_users/_superusers
  rows. IAM_ENDPOINT (hanzo.id, or an in-process iam.Embed in the fused
  daemon) is required; Base validates IAM JWTs via JWKS only — superuser is
  the IAM admin claim, resolved to an ephemeral unpersisted record.
- Extract the IDV reverse-proxy (/v1/idv/*) into idv_proxy.go — it was
  mis-colocated in the embedded onboarding file and is independent of IAM.
- Bind the SQL console (/v1/sql) and test it through real IAM JWTs against a
  JWKS stub (no hardcoded record tokens, no persisted rows).
2026-06-10 21:29:14 -07:00

417 lines
9.7 KiB
Go

// Package ghupdate implements a new command to selfupdate the current
// Base executable with the latest GitHub release.
//
// Example usage:
//
// ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
package ghupdate
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/archive"
"github.com/hanzoai/base/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 ghupdate plugin.
//
// NB! This plugin is considered experimental and its config options may change in the future.
type Config struct {
// Owner specifies the account owner of the repository (default to "base").
Owner string
// Repo specifies the name of the repository (default to "base").
Repo string
// ArchiveExecutable specifies the name of the executable file in the release archive
// (default to "base"; 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 ghupdate 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 ghupdate plugin to the provided app instance.
func Register(app core.App, rootCmd *cobra.Command, config Config) error {
p := &plugin{
app: app,
currentVersion: rootCmd.Version,
config: config,
}
if p.config.Owner == "" {
p.config.Owner = "base"
}
if p.config.Repo == "" {
p.config.Repo = "base"
}
if p.config.ArchiveExecutable == "" {
p.config.ArchiveExecutable = "base"
}
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 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(
p.config.Context,
p.config.HttpClient,
p.config.Owner,
p.config.Repo,
)
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")
}
asset, err := latest.findAssetBySuffix(suffix)
if err != nil {
return err
}
releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
defer os.RemoveAll(releaseDir)
color.Yellow("Downloading %s...", asset.Name)
// download the release asset
assetZip := filepath.Join(releaseDir, asset.Name)
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
return err
}
color.Yellow("Extracting %s...", asset.Name)
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
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",
"old", renamedOldExec,
"new", oldExec,
"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)
}
if withBackup {
color.Yellow("Creating data backup...")
backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
tryToRevertExecChanges()
return err
}
}
color.HiBlack("---")
color.Green("Update completed successfully! You can start the executable as usual.")
// 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"
// (@todo consider moving to a config option)
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
color.Cyan(releaseNotes)
fmt.Print("\n")
}
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 ""
}
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
}