feat(updater): add web self-update endpoint and updater package
This commit is contained in:
parent
7b3f47128f
commit
e93d7a01a7
6 changed files with 488 additions and 1 deletions
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
|
|
@ -45,7 +46,8 @@ func NewPicoclawCommand() *cobra.Command {
|
||||||
migrate.NewMigrateCommand(),
|
migrate.NewMigrateCommand(),
|
||||||
skills.NewSkillsCommand(),
|
skills.NewSkillsCommand(),
|
||||||
model.NewModelCommand(),
|
model.NewModelCommand(),
|
||||||
version.NewVersionCommand(),
|
updater.NewUpdateCommand("picoclaw"),
|
||||||
|
version.NewVersionCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -23,6 +23,7 @@ require (
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
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/modelcontextprotocol/go-sdk v1.4.1
|
||||||
github.com/mymmrac/telego v1.7.0
|
github.com/mymmrac/telego v1.7.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
|
|
@ -46,6 +47,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
aead.dev/minisign v0.2.0 // indirect
|
||||||
filippo.io/edwards25519 v1.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/aws/protocol/eventstream v1.7.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||||
|
|
|
||||||
10
go.sum
10
go.sum
|
|
@ -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=
|
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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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/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 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
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 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
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=
|
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||||
|
|
@ -300,8 +304,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-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-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-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-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-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.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
|
@ -322,6 +328,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-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-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-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
|
@ -344,11 +351,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-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-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-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-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-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-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-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-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-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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -362,6 +371,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.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
|
|
||||||
418
pkg/updater/updater.go
Normal file
418
pkg/updater/updater.go
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/minio/selfupdate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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, err := findAssetURL(releaseURL, platform, arch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download asset to temp file
|
||||||
|
tmpFile, err := os.CreateTemp("", "picoclaw-release-*.archive")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer func() { _ = tmpFile.Close() }()
|
||||||
|
|
||||||
|
resp, err := http.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(binPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if err := selfupdate.Apply(f, selfupdate.Options{}); 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 {
|
||||||
|
return UpdateSelfFromRelease("", "", "", programName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 findAssetURL(releaseURL, platform, arch string) (string, error) {
|
||||||
|
if looksLikeDirectAssetURL(releaseURL) {
|
||||||
|
return releaseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := buildReleaseAPIURL(releaseURL)
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = "https://api.github.com/repos/sipeed/picoclaw/releases/latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.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"`
|
||||||
|
} `json:"assets"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefer exact platform+arch match
|
||||||
|
var fallback string
|
||||||
|
for _, a := range data.Assets {
|
||||||
|
n := strings.ToLower(a.Name)
|
||||||
|
if platform != "" && arch != "" {
|
||||||
|
if strings.Contains(n, strings.ToLower(platform)) && strings.Contains(n, strings.ToLower(arch)) {
|
||||||
|
return a.BrowserDownloadURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if platform != "" && strings.Contains(n, strings.ToLower(platform)) {
|
||||||
|
if fallback == "" {
|
||||||
|
fallback = a.BrowserDownloadURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fallback == "" {
|
||||||
|
fallback = a.BrowserDownloadURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fallback != "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("no suitable release asset found")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
fp := filepath.Join(destDir, f.Name)
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
_ = os.MkdirAll(fp, f.Mode())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fp), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.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)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(destDir, 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 extractTar(archivePath, destDir string) error {
|
||||||
|
f, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
tr := tar.NewReader(f)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(destDir, 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
|
||||||
|
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
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Launcher service parameters (port/public)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
h.registerLauncherConfigRoutes(mux)
|
||||||
|
|
||||||
|
// Self-update endpoint (requires dashboard auth)
|
||||||
|
h.registerUpdateRoutes(mux)
|
||||||
|
|
||||||
// Runtime build/version metadata
|
// Runtime build/version metadata
|
||||||
h.registerVersionRoutes(mux)
|
h.registerVersionRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
52
web/backend/api/update.go
Normal file
52
web/backend/api/update.go
Normal 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"})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue