selfupdate: add -update

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s236be243dde9276dk
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 285d7d8..bd0e088 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -37,6 +37,7 @@
 	"sketch.dev/skabandclient"
 	"sketch.dev/skribe"
 	"sketch.dev/termui"
+	"sketch.dev/update"
 	"sketch.dev/webui"
 )
 
@@ -86,6 +87,10 @@
 		return nil
 	}
 
+	if flagArgs.doUpdate {
+		return doSelfUpdate()
+	}
+
 	if flagArgs.listModels {
 		fmt.Println("Available models:")
 		fmt.Println("- claude (default, uses Anthropic service)")
@@ -228,6 +233,7 @@
 	baseImage    string
 	linkToGitHub bool
 	ignoreSig    bool
+	doUpdate     bool
 
 	gitUsername         string
 	gitEmail            string
@@ -281,6 +287,7 @@
 	userFlags.BoolVar(&flags.listModels, "list-models", false, "list all available models and exit")
 	userFlags.BoolVar(&flags.verbose, "verbose", false, "enable verbose output")
 	userFlags.BoolVar(&flags.version, "version", false, "print the version and exit")
+	userFlags.BoolVar(&flags.doUpdate, "update", false, "update to the latest version of sketch")
 	userFlags.IntVar(&flags.sshPort, "ssh-port", 0, "the host port number that the container's ssh server will listen on, or a randomly chosen port if this value is 0")
 	userFlags.BoolVar(&flags.forceRebuild, "force-rebuild-container", false, "rebuild Docker container")
 	// Get the default image info for help text
@@ -963,3 +970,11 @@
 	}
 	return strings.TrimSpace(string(out))
 }
+
+func doSelfUpdate() error {
+	executable, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("failed to get executable path: %w", err)
+	}
+	return update.Do(context.Background(), release, executable)
+}
diff --git a/go.mod b/go.mod
index 5e1319e..207caf6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,12 +3,14 @@
 go 1.24.5
 
 require (
+	github.com/Masterminds/semver/v3 v3.4.0
 	github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b
 	github.com/chromedp/chromedp v0.13.6
 	github.com/creack/pty v1.1.24
 	github.com/dustin/go-humanize v1.0.1
 	github.com/evanw/esbuild v0.25.2
 	github.com/fatih/color v1.18.0
+	github.com/fynelabs/selfupdate v0.2.1
 	github.com/gliderlabs/ssh v0.3.8
 	github.com/google/uuid v1.6.0
 	github.com/kevinburke/ssh_config v1.2.0
diff --git a/go.sum b/go.sum
index 8792d6c..e907145 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b h1:jJmiCljLNTaq/O1ju9Bzz2MPpFlmiTn0F7LwCoeDZVw=
@@ -20,6 +22,8 @@
 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fynelabs/selfupdate v0.2.1 h1:jaU85o1tnzsyICg29YfQurQPlMV4oSHLmomFIGatsgk=
+github.com/fynelabs/selfupdate v0.2.1/go.mod h1:V2z7H295LzTph5mYBnm3EDRN+oKf7G2VU5B0pc77jdw=
 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
 github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
diff --git a/selfupdate/ed25519.pem b/update/ed25519.pem
similarity index 100%
rename from selfupdate/ed25519.pem
rename to update/ed25519.pem
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
+}