selfupdate: add -update

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s236be243dde9276dk
diff --git a/update/ed25519.pem b/update/ed25519.pem
new file mode 100644
index 0000000..15dfa4b
--- /dev/null
+++ b/update/ed25519.pem
@@ -0,0 +1,3 @@
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAV4E+fJoShyziYCA5HjaafxIEX0DzIzwPwTyKOlappE8=
+-----END PUBLIC KEY-----
diff --git a/update/update.go b/update/update.go
new file mode 100644
index 0000000..3fe8a5d
--- /dev/null
+++ b/update/update.go
@@ -0,0 +1,188 @@
+package update
+
+import (
+	"context"
+	"crypto/ed25519"
+	"crypto/x509"
+	_ "embed"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"net/http"
+	"runtime"
+	"strings"
+	"sync"
+
+	"github.com/Masterminds/semver/v3"
+	"github.com/fynelabs/selfupdate"
+)
+
+//go:embed ed25519.pem
+var publicKeyPEM string
+
+// publicKey returns the parsed Ed25519 public key for signature verification
+var publicKey = sync.OnceValue(func() ed25519.PublicKey {
+	block, _ := pem.Decode([]byte(publicKeyPEM))
+	if block == nil {
+		panic("failed to decode PEM block containing public key")
+	}
+	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		panic(fmt.Sprintf("failed to parse public key: %v", err))
+	}
+	key, ok := pub.(ed25519.PublicKey)
+	if !ok {
+		panic(fmt.Sprintf("not an ed25519 public key: %T", pub))
+	}
+	return key
+})
+
+// Do updates sketch in-place.
+func Do(ctx context.Context, currentVersion, binaryPath string) error {
+	fmt.Printf("Current version: %s. Checking for updates...\n", currentVersion)
+
+	currentVer, err := semver.NewVersion(currentVersion)
+	if err != nil {
+		return fmt.Errorf("could not parse current version %q as semver: %w", currentVersion, err)
+	}
+
+	source := &ghSource{currentVer: currentVer}
+	if err := source.initialize(ctx); err != nil {
+		return err
+	}
+
+	if !source.latestVer.GreaterThan(currentVer) {
+		fmt.Printf("%s is up to date.\n", currentVersion)
+		return nil
+	}
+	fmt.Printf("Updating to %s...\n", source.latestVer)
+
+	if err := selfupdate.ManualUpdate(source, publicKey()); err != nil {
+		return fmt.Errorf("failed to perform update: %w", err)
+	}
+	fmt.Printf("Updated to %s.\n", source.latestVer)
+	return nil
+}
+
+// ghSource implements selfupdate.Source for sketch GitHub releases
+type ghSource struct {
+	currentVer *semver.Version
+	latestVer  *semver.Version
+	asset      ghAsset
+	signature  [64]byte
+}
+
+// ghRelease represents a GitHub release
+type ghRelease struct {
+	TagName string    `json:"tag_name"`
+	Assets  []ghAsset `json:"assets"`
+}
+
+// ghAsset represents a release asset
+type ghAsset struct {
+	Name               string `json:"name"`
+	BrowserDownloadURL string `json:"browser_download_url"`
+	Size               int64  `json:"size"`
+}
+
+// initialize fetches the latest release information if not already done
+func (gs *ghSource) initialize(ctx context.Context) error {
+	url := "https://api.github.com/repos/boldsoftware/sketch/releases/latest"
+	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+	if err != nil {
+		return fmt.Errorf("failed to create request: %w", err)
+	}
+	req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("failed to fetch release info: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+	}
+	var release ghRelease
+	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
+		return fmt.Errorf("failed to decode release info: %w", err)
+	}
+
+	gs.latestVer, err = semver.NewVersion(release.TagName)
+	if err != nil {
+		return fmt.Errorf("invalid latest version %q: %w", release.TagName, err)
+	}
+
+	// Find the appropriate asset for current platform
+	gs.asset, err = gs.assetForPlatform(release, runtime.GOOS, runtime.GOARCH)
+	if err != nil {
+		return fmt.Errorf("failed to find asset for current platform: %w", err)
+	}
+
+	// Download and parse the signature
+	if err := gs.downloadSignature(ctx); err != nil {
+		return fmt.Errorf("failed to download signature: %w", err)
+	}
+	return nil
+}
+
+// assetForPlatform finds the appropriate asset for the given platform
+func (gs *ghSource) assetForPlatform(release ghRelease, goos, goarch string) (ghAsset, error) {
+	for _, asset := range release.Assets {
+		if strings.HasSuffix(asset.Name, goos+"_"+goarch) {
+			return asset, nil
+		}
+	}
+	return ghAsset{}, fmt.Errorf("no asset found for platform %s/%s", goos, goarch)
+}
+
+// downloadSignature downloads and parses the signature file
+func (gs *ghSource) downloadSignature(ctx context.Context) error {
+	sigURL := gs.asset.BrowserDownloadURL + ".ed25519"
+	req, err := http.NewRequestWithContext(ctx, "GET", sigURL, nil)
+	if err != nil {
+		return fmt.Errorf("failed to create signature request: %w", err)
+	}
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("failed to download signature: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("unexpected status code for signature: %d", resp.StatusCode)
+	}
+	_, err = io.ReadFull(resp.Body, gs.signature[:])
+	if err != nil {
+		return fmt.Errorf("failed to read signature data: %w", err)
+	}
+	return nil
+}
+
+// Get downloads the binary for the specified version
+func (gs *ghSource) Get(v *selfupdate.Version) (io.ReadCloser, int64, error) {
+	req, err := http.NewRequest("GET", gs.asset.BrowserDownloadURL, nil)
+	if err != nil {
+		return nil, 0, fmt.Errorf("failed to create download request: %w", err)
+	}
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, 0, fmt.Errorf("failed to download binary: %w", err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		resp.Body.Close()
+		return nil, 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+	}
+	return resp.Body, gs.asset.Size, nil
+}
+
+// GetSignature returns the signature for the binary
+func (gs *ghSource) GetSignature() ([64]byte, error) {
+	return gs.signature, nil
+}
+
+// LatestVersion returns the latest version available
+func (gs *ghSource) LatestVersion() (*selfupdate.Version, error) {
+	return &selfupdate.Version{Number: gs.latestVer.String()}, nil
+}