feat(updater): robust self-update selection & extraction (nightly default) (#2201)

* feat(updater): add web self-update endpoint and updater package

* feat(selfupgrade): when url empty, using GetTestReleaseAPIURL for test .

* feat(selfupgrade):  only GetTestReleaseAPIURL  .

* feat(upgrade): cli  $0 update work well!

* fix(ci): fix ci err

* fix(test): fix ci test

* fix(ci): fix ci  lint fmt err

* test(updater): add test for updater

* fix(ci): fix ci  lint var copy err

* fix(ci): retry ci

* updater: require checksum verification, prefer API digest, verify SHA256, fix zip extraction, update tests

* fix(lint): lint fixed

* fix(lint): lint fixed2

* updater: stream download and verify sha256; add http client timeout and progress

Avoid double-download by streaming asset into temp file while computing SHA256 and verifying against checksum; replace http.Get with shared httpClient (2m timeout) to prevent hangs; add simple stderr progress display; remove unused helpers.
This commit is contained in:
sky5454 2026-04-01 23:41:32 +08:00 committed by GitHub
parent e2a9bb97c7
commit 49e61fa07f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 875 additions and 0 deletions

View file

@ -24,6 +24,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/updater"
)
func NewPicoclawCommand() *cobra.Command {
@ -45,6 +46,7 @@ func NewPicoclawCommand() *cobra.Command {
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
model.NewModelCommand(),
updater.NewUpdateCommand("picoclaw"),
version.NewVersionCommand(),
)

View file

@ -43,6 +43,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"onboard",
"skills",
"status",
"update",
"version",
}

2
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/mymmrac/telego v1.7.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
@ -48,6 +49,7 @@ require (
)
require (
aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect

11
go.sum
View file

@ -1,3 +1,5 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
@ -184,6 +186,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
@ -308,7 +312,10 @@ golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
@ -329,6 +336,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@ -351,11 +359,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -369,6 +379,7 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=

707
pkg/updater/updater.go Normal file
View file

@ -0,0 +1,707 @@
package updater
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/minio/selfupdate"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/config"
)
// httpClient is a shared HTTP client used for release checks and downloads.
// The Timeout value applies to the entire HTTP request: dialing, TLS
// handshake, redirects, and reading the response body. It is NOT only
// a connection (dial) timeout. To control lower-level timeouts (dial,
// TLS handshake, response header wait), supply a custom Transport with
// an appropriately configured net.Dialer.
var httpClient = &http.Client{Timeout: 2 * time.Minute}
// DownloadAndExtractRelease downloads a release archive (or uses a direct
// asset URL) and extracts it to a temporary directory. It returns the
// extraction directory on success. If releaseURL is empty, the latest
// release of the current project is used. platform/arch can be used to
// select the correct asset (e.g. "linux", "amd64").
func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) {
assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch)
if err != nil {
return "", err
}
// Download asset to temp file. Use the asset URL extension so
// extractArchive can detect the archive format (zip/tar.gz/tar).
tmpPattern := "picoclaw-release-*"
if u, perr := url.Parse(assetURL); perr == nil {
base := filepath.Base(u.Path)
lbase := strings.ToLower(base)
switch {
case strings.HasSuffix(lbase, ".zip"):
tmpPattern += ".zip"
case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"):
tmpPattern += ".tar.gz"
case strings.HasSuffix(lbase, ".tar"):
tmpPattern += ".tar"
default:
tmpPattern += ".archive"
}
} else {
tmpPattern += ".archive"
}
tmpFile, err := os.CreateTemp("", tmpPattern)
if err != nil {
return "", err
}
tmpPath := tmpFile.Name()
defer tmpFile.Close()
resp, err := httpClient.Get(assetURL)
if err != nil {
os.Remove(tmpPath)
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode)
}
// Stream download while computing SHA256 to avoid a second download.
// Also show a simple progress line to stderr so users see activity.
h := sha256.New()
pw := &progressWriter{total: resp.ContentLength}
mw := io.MultiWriter(tmpFile, h, pw)
if _, err = io.Copy(mw, resp.Body); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
// ensure final progress line ends with newline
pw.Finish()
// verify checksum if available
if checksum != "" {
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, checksum) {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum)
}
}
// Extract
destDir, err := os.MkdirTemp("", "picoclaw-extract-*")
if err != nil {
os.Remove(tmpPath)
return "", err
}
if err := extractArchive(tmpPath, destDir); err != nil {
os.Remove(tmpPath)
os.RemoveAll(destDir)
return "", err
}
// cleanup archive file; keep extracted contents
_ = os.Remove(tmpPath)
return destDir, nil
}
// UpdateSelfFromRelease downloads the release matching the given parameters,
// extracts it and applies the binary named programName to update the
// currently running executable using minio/selfupdate.
// If releaseURL is empty, the latest release is used. If platform or arch
// is empty, runtime values are used.
func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error {
if platform == "" {
platform = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
dir, err := DownloadAndExtractRelease(releaseURL, platform, arch)
if err != nil {
return err
}
defer os.RemoveAll(dir)
binPath, err := findBinaryInDir(dir, programName)
if err != nil {
return err
}
// ensure executable bit on non-windows
if runtime.GOOS != "windows" {
_ = os.Chmod(binPath, 0o755)
}
f, err := os.Open(binPath)
if err != nil {
return err
}
defer f.Close()
// Backup current executable so we can roll back if needed.
var opts selfupdate.Options
if exePath, err := os.Executable(); err == nil {
opts.OldSavePath = exePath + ".old"
}
if err := selfupdate.Apply(f, opts); err != nil {
return fmt.Errorf("apply update: %w", err)
}
return nil
}
// UpdateSelf updates the running executable by fetching the latest release
// and applying the binary matching programName.
func UpdateSelf(programName string) error {
// By default, select the latest stable release when no explicit
// release URL is provided. Use --nightly or a custom URL to override.
return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName)
}
// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner.
// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest
func GetReleaseAPIURL(owner string) string {
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner)
}
// GetProdReleaseAPIURL returns the production release API URL (upstream).
func GetProdReleaseAPIURL() string {
return GetReleaseAPIURL("sipeed")
}
// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag.
// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly
func GetReleaseTagAPIURL(owner, tag string) string {
return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag)
}
// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo.
func GetNightlyReleaseAPIURL() string {
return GetReleaseTagAPIURL("sipeed", "nightly")
}
// findAssetURL resolves the appropriate asset URL for the given release
// selector. It accepts direct archive URLs as well as GitHub release URLs
// or empty (latest release for the project).
func findAssetInfo(releaseURL, platform, arch string) (string, string, error) {
// returns (assetURL, sha256ChecksumHex, error)
if looksLikeDirectAssetURL(releaseURL) {
return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL)
}
apiURL := buildReleaseAPIURL(releaseURL)
if apiURL == "" {
// If caller provided an empty releaseURL, default to the
// production latest release API URL (stable release).
apiURL = GetProdReleaseAPIURL()
}
resp, err := httpClient.Get(apiURL)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode)
}
var data struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", "", err
}
// Selection order: platform -> arch -> extension.
platformLower := strings.ToLower(platform)
archLower := strings.ToLower(arch)
isZip := func(name string) bool {
return strings.HasSuffix(name, ".zip")
}
isTarGz := func(name string) bool {
return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz")
}
isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") }
// collect indices of assets that contain platform (if provided)
var platformIdx []int
for i, a := range data.Assets {
n := strings.ToLower(a.Name)
if platform == "" || strings.Contains(n, platformLower) {
platformIdx = append(platformIdx, i)
}
}
pickBest := func(idxs []int) (string, int, bool) {
if len(idxs) == 0 {
return "", -1, false
}
// prefer arch matches within idxs; if arch was specified but
// no arch match exists among idxs, treat as no candidate.
var archIdx []int
if arch != "" {
aliases := archAliases(archLower)
for _, i := range idxs {
n := strings.ToLower(data.Assets[i].Name)
for _, ali := range aliases {
if strings.Contains(n, ali) {
archIdx = append(archIdx, i)
break
}
}
}
if len(archIdx) == 0 {
return "", -1, false
}
}
candidates := archIdx
if len(candidates) == 0 {
candidates = idxs
}
// extension preference
if platformLower == "windows" {
// prefer .zip only
for _, i := range candidates {
if isZip(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
// if no zip found, fallthrough to first candidate
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
}
// non-windows: prefer tar.gz/tgz, then tar, then zip
for _, i := range candidates {
if isTarGz(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
for _, i := range candidates {
if isTar(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
for _, i := range candidates {
if isZip(strings.ToLower(data.Assets[i].Name)) {
return data.Assets[i].BrowserDownloadURL, i, true
}
}
// fallback to first candidate
return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true
}
// Try platform matches first
if url, idx, ok := pickBest(platformIdx); ok {
// attempt to find checksum: prefer asset digest from API if present
if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" {
dLower := strings.ToLower(d)
if strings.HasPrefix(dLower, "sha256:") {
hexpart := strings.TrimPrefix(dLower, "sha256:")
return url, hexpart, nil
}
// If digest already looks like a 64-hex, return it
if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok {
return url, dLower, nil
}
}
// Look for checksum assets and verify by computing the asset's sha256.
for j, a := range data.Assets {
n := strings.ToLower(a.Name)
if strings.Contains(n, "sha256") ||
strings.Contains(n, "sha256sum") ||
strings.Contains(n, "checksums") ||
strings.HasSuffix(n, ".sha256") ||
strings.HasSuffix(n, ".sha256sum") {
resp2, err := httpClient.Get(data.Assets[j].BrowserDownloadURL)
if err != nil {
continue
}
bs, err := io.ReadAll(resp2.Body)
resp2.Body.Close()
if err != nil {
continue
}
if h, ok := findHashInChecksumContent(bs, url); ok {
return url, h, nil
}
}
}
// No checksum found for the selected platform asset -> error
return "", "", fmt.Errorf("no checksum found for asset %s", url)
}
// No platform match — require explicit platform+arch; fail fast.
return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch)
}
func looksLikeDirectAssetURL(u string) bool {
if u == "" {
return false
}
lower := strings.ToLower(u)
if strings.HasSuffix(lower, ".zip") ||
strings.HasSuffix(lower, ".tar.gz") ||
strings.HasSuffix(lower, ".tgz") ||
strings.HasSuffix(lower, ".tar") {
return true
}
if strings.Contains(lower, "/releases/download/") {
return true
}
return false
}
func buildReleaseAPIURL(releaseURL string) string {
if releaseURL == "" {
return ""
}
if strings.Contains(releaseURL, "api.github.com") {
return releaseURL
}
u, err := url.Parse(releaseURL)
if err != nil {
return ""
}
if u.Host != "github.com" {
return ""
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 {
return ""
}
owner := parts[0]
repo := parts[1]
// if tag specified
if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" {
tag := parts[4]
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
}
// default to latest
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
}
// NOTE: helper functions to compute SHA256 from URL/path were removed
// after refactoring to stream the download and verify the checksum
// during the single download to avoid double-transfer.
// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the
// checksum file content that corresponds to assetURL. It returns the
// found hash (lowercase) and true, or "", false if not found.
func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) {
s := strings.ToLower(string(bs))
var assetBase string
if u, err := url.Parse(assetURL); err == nil {
assetBase = strings.ToLower(filepath.Base(u.Path))
} else {
assetBase = strings.ToLower(filepath.Base(assetURL))
}
re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`)
// prefer a line containing the asset filename
for _, line := range strings.Split(s, "\n") {
if strings.Contains(line, assetBase) {
if m := re.FindString(line); m != "" {
return m, true
}
}
}
// fallback: if there's exactly one unique 64-hex value, return it
matches := re.FindAllString(s, -1)
uniq := map[string]struct{}{}
for _, m := range matches {
uniq[m] = struct{}{}
}
if len(uniq) == 1 {
for k := range uniq {
return k, true
}
}
return "", false
}
// progressWriter implements io.Writer and prints a simple progress
// line to stderr while bytes are written. It is intended to be used
// as one writer in an io.MultiWriter so we can stream-to-disk, compute
// the sha256, and update the progress display in a single pass.
type progressWriter struct {
total int64
written int64
last time.Time
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n := len(p)
pw.written += int64(n)
now := time.Now()
if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) {
pw.print()
pw.last = now
}
return n, nil
}
func (pw *progressWriter) print() {
if pw.total > 0 {
pct := float64(pw.written) * 100.0 / float64(pw.total)
fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct)
} else {
fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written))
}
}
func (pw *progressWriter) Finish() {
pw.print()
fmt.Fprintln(os.Stderr, "")
}
func humanBytes(n int64) string {
f := float64(n)
const (
KB = 1024.0
MB = KB * 1024.0
GB = MB * 1024.0
)
switch {
case f >= GB:
return fmt.Sprintf("%.2f GB", f/GB)
case f >= MB:
return fmt.Sprintf("%.2f MB", f/MB)
case f >= KB:
return fmt.Sprintf("%.2f KB", f/KB)
default:
return fmt.Sprintf("%d B", n)
}
}
// archAliases returns common name variants for an architecture string
// so we can match release asset names like "x86_64" vs Go's "amd64".
// archAliases returns name variants for an architecture string.
// If `arch` is empty or matches the local runtime.GOARCH, prefer the
// compile-time architecture aliases provided by archAliasesForLocal
// (implemented per-architecture via build tags). For other `arch`
// values we use a small synonyms map.
func archAliases(arch string) []string {
a := strings.ToLower(arch)
if syns, ok := archSynonyms[a]; ok {
return syns
}
return []string{a}
}
var archSynonyms = map[string][]string{
"amd64": {"amd64", "x86_64", "x64"},
"x86_64": {"amd64", "x86_64", "x64"},
"x64": {"amd64", "x86_64", "x64"},
"386": {"386", "x86"},
"x86": {"386", "x86"},
"arm64": {"arm64", "aarch64"},
"aarch64": {"arm64", "aarch64"},
"arm": {"arm"},
}
func extractArchive(archivePath, destDir string) error {
lower := strings.ToLower(archivePath)
if strings.HasSuffix(lower, ".zip") {
return extractZip(archivePath, destDir)
}
// treat .tar.gz and .tgz as gzip+tar
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return extractTarGz(archivePath, destDir)
}
if strings.HasSuffix(lower, ".tar") {
return extractTar(archivePath, destDir)
}
// fallback: try tar.gz
return extractTarGz(archivePath, destDir)
}
func extractZip(archivePath, destDir string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer r.Close()
destClean := filepath.Clean(destDir)
for _, f := range r.File {
target := filepath.Clean(filepath.Join(destClean, f.Name))
if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean {
return fmt.Errorf("path traversal detected: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode())
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
rc.Close()
out.Close()
return err
}
rc.Close()
out.Close()
}
return nil
}
func extractTarGz(archivePath, destDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gzr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
return extractTarFromReader(tr, destDir)
}
func extractTar(archivePath, destDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
tr := tar.NewReader(f)
return extractTarFromReader(tr, destDir)
}
// extractTarFromReader contains logic common to extracting entries from a
// tar.Reader and is used by both extractTarGz and extractTar to avoid
// duplicated code (golangci-lint: dupl).
func extractTarFromReader(tr *tar.Reader, destDir string) error {
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name))
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) &&
target != filepath.Clean(destDir) {
return fmt.Errorf("path traversal detected: %s", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
}
}
return nil
}
func findBinaryInDir(dir, programName string) (string, error) {
wanted := []string{programName}
if runtime.GOOS == "windows" {
wanted = append([]string{programName + ".exe"}, wanted...)
} else {
// also accept programs with .exe in archives targeting windows
wanted = append(wanted, programName+".exe")
}
var found string
if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil || found != "" {
return err
}
if d.IsDir() {
return nil
}
base := filepath.Base(p)
for _, w := range wanted {
if base == w {
found = p
return io.EOF // use EOF to stop walking early
}
}
return nil
}); err != nil && err != io.EOF {
return "", err
}
if found == "" {
return "", fmt.Errorf("binary %q not found in archive", programName)
}
return found, nil
}
// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease.
func NewUpdateCommand(binaryName string) *cobra.Command {
var urlStr, platform, arch string
cmd := &cobra.Command{
Use: "update",
Short: "Check and apply updates from GitHub releases",
RunE: func(cmd *cobra.Command, args []string) error {
if platform == "" {
platform = runtime.GOOS
}
if arch == "" {
arch = runtime.GOARCH
}
fmt.Printf("Current version: %s\n", config.FormatVersion())
if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil {
return err
}
fmt.Println("Update applied; restart to use the new version.")
return nil
},
}
cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page")
cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)")
cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)")
return cmd
}

View file

@ -0,0 +1,97 @@
package updater
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// matchesMagic checks whether the file at path looks like a platform binary
// by inspecting magic bytes (ELF for linux, MZ for windows).
func matchesMagic(path, platform string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
buf := make([]byte, 4)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return false, err
}
if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
return strings.Contains(platform, "linux"), nil
}
if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' {
return strings.Contains(platform, "windows"), nil
}
return false, nil
}
// TestDownloadAndExtractRelease_RealPlatforms downloads the latest release
// asset for multiple platform/arch combos and inspects the extracted
// artifacts to ensure a binary-like file is present. This is a network test
// and is skipped in short mode.
func TestDownloadAndExtractRelease_RealPlatforms(t *testing.T) {
if testing.Short() {
t.Skip("skipping network tests in short mode")
}
combos := []struct{ platform, arch string }{
{"linux", "amd64"},
{"linux", "arm64"},
{"windows", "amd64"},
{"windows", "arm64"},
}
apiURL := GetProdReleaseAPIURL()
for _, c := range combos {
t.Run(c.platform+"_"+c.arch, func(t *testing.T) {
assetURL, checksum, err := findAssetInfo(apiURL, c.platform, c.arch)
if err != nil {
// If no checksum could be located for this asset, skip this
// combo rather than failing — we require signed/checksummed
// releases for real-network tests.
t.Skipf("skipping %s/%s: %v", c.platform, c.arch, err)
}
t.Logf("asset URL: %s checksum: %s", assetURL, checksum)
// Pass the release API URL (not the direct asset URL) so
// DownloadAndExtractRelease can locate and verify the asset.
dir, err := DownloadAndExtractRelease(apiURL, c.platform, c.arch)
if err != nil {
t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", c.platform, c.arch, err)
}
defer os.RemoveAll(dir)
var found bool
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
info, err := d.Info()
if err != nil {
return err
}
if info.Size() < 64 {
return nil
}
ok, err := matchesMagic(path, c.platform)
if err != nil {
return err
}
if ok {
found = true
t.Logf("found artifact: %s (size=%d)", path, info.Size())
// continue walking to list all
}
return nil
})
if !found {
t.Fatalf("no binary-like artifact found for %s/%s", c.platform, c.arch)
}
})
}
}

View file

@ -81,6 +81,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
// Self-update endpoint (requires dashboard auth)
h.registerUpdateRoutes(mux)
// Runtime build/version metadata
h.registerVersionRoutes(mux)

52
web/backend/api/update.go Normal file
View file

@ -0,0 +1,52 @@
package api
import (
"encoding/json"
"net/http"
"github.com/sipeed/picoclaw/pkg/updater"
)
// registerUpdateRoutes registers the self-update endpoint.
func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/update", h.handleUpdate)
}
type updateRequest struct {
URL string `json:"url,omitempty"`
Binary string `json:"binary,omitempty"`
}
type updateResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"})
return
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
var req updateRequest
if err := dec.Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"})
return
}
binary := req.Binary
if binary == "" {
binary = "picoclaw-launcher"
}
if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()})
return
}
_ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"})
}