新增可复用Go客户端模块
This commit is contained in:
parent
8c1da695cd
commit
99342696ea
@ -1,32 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"context"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"scout/internal/registry"
|
"scout/pkg/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type packageInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
SHA256 string `json:"sha256"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateEvent struct {
|
|
||||||
Package packageInfo `json:"package"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
printUsage()
|
printUsage()
|
||||||
@ -37,6 +22,8 @@ func main() {
|
|||||||
checkCommand(os.Args[2:])
|
checkCommand(os.Args[2:])
|
||||||
case "download":
|
case "download":
|
||||||
downloadCommand(os.Args[2:])
|
downloadCommand(os.Args[2:])
|
||||||
|
case "upload":
|
||||||
|
uploadCommand(os.Args[2:])
|
||||||
case "watch":
|
case "watch":
|
||||||
watchCommand(os.Args[2:])
|
watchCommand(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
@ -52,9 +39,12 @@ func checkCommand(args []string) {
|
|||||||
current := flags.String("current", "", "当前版本")
|
current := flags.String("current", "", "当前版本")
|
||||||
flags.Parse(args)
|
flags.Parse(args)
|
||||||
requirePackage(*name)
|
requirePackage(*name)
|
||||||
var latest packageInfo
|
api := newClient(*server, "")
|
||||||
requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest)
|
latest, available, err := api.CheckUpdate(context.Background(), *name, *current)
|
||||||
if registry.CompareVersions(latest.Version, *current) > 0 {
|
if err != nil {
|
||||||
|
fatal("检查更新失败: %v", err)
|
||||||
|
}
|
||||||
|
if available {
|
||||||
fmt.Printf("发现更新:%s %s -> %s\n", latest.Name, *current, latest.Version)
|
fmt.Printf("发现更新:%s %s -> %s\n", latest.Name, *current, latest.Version)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -69,41 +59,55 @@ func downloadCommand(args []string) {
|
|||||||
output := flags.String("output", "", "保存路径")
|
output := flags.String("output", "", "保存路径")
|
||||||
flags.Parse(args)
|
flags.Parse(args)
|
||||||
requirePackage(*name)
|
requirePackage(*name)
|
||||||
if *version == "" {
|
|
||||||
var latest packageInfo
|
|
||||||
requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest)
|
|
||||||
*version = latest.Version
|
|
||||||
}
|
|
||||||
if *output == "" {
|
if *output == "" {
|
||||||
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, *version))
|
resolvedVersion := *version
|
||||||
|
if resolvedVersion == "" {
|
||||||
|
resolvedVersion = "latest"
|
||||||
|
}
|
||||||
|
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, resolvedVersion))
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/api/v1/packages/%s/%s/download", strings.TrimRight(*server, "/"), *name, *version)
|
api := newClient(*server, "")
|
||||||
response, err := http.Get(url)
|
item, err := api.Download(context.Background(), *name, *version, *output)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal("请求下载失败: %v", err)
|
fatal("下载失败: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
fmt.Printf("下载完成:%s\n版本:%s\nSHA-256:%s\n", *output, item.Version, item.SHA256)
|
||||||
if response.StatusCode != http.StatusOK {
|
}
|
||||||
fatalResponse(response)
|
|
||||||
|
func uploadCommand(args []string) {
|
||||||
|
flags := flag.NewFlagSet("upload", flag.ExitOnError)
|
||||||
|
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
||||||
|
token := flags.String("token", "", "开发者令牌")
|
||||||
|
name := flags.String("package", "", "包名")
|
||||||
|
version := flags.String("version", "", "版本")
|
||||||
|
filePath := flags.String("file", "", "包文件路径")
|
||||||
|
description := flags.String("description", "", "包描述")
|
||||||
|
platform := flags.String("platform", "", "运行平台")
|
||||||
|
architecture := flags.String("architecture", "", "运行架构")
|
||||||
|
flags.Parse(args)
|
||||||
|
requirePackage(*name)
|
||||||
|
if *version == "" || *filePath == "" {
|
||||||
|
fatal("必须指定 --version 和 --file")
|
||||||
}
|
}
|
||||||
file, err := os.Create(*output)
|
file, err := os.Open(*filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal("创建输出文件失败: %v", err)
|
fatal("打开包文件失败: %v", err)
|
||||||
}
|
}
|
||||||
_, copyErr := io.Copy(file, response.Body)
|
defer file.Close()
|
||||||
closeErr := file.Close()
|
api := newClient(*server, *token)
|
||||||
if copyErr != nil || closeErr != nil {
|
item, err := api.Upload(context.Background(), client.UploadRequest{
|
||||||
fatal("保存包文件失败")
|
Name: *name,
|
||||||
}
|
Version: *version,
|
||||||
digest, err := fileSHA256(*output)
|
Description: *description,
|
||||||
|
Platform: *platform,
|
||||||
|
Architecture: *architecture,
|
||||||
|
FileName: filepath.Base(*filePath),
|
||||||
|
File: file,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal("校验包文件失败: %v", err)
|
fatal("上传失败: %v", err)
|
||||||
}
|
}
|
||||||
if expected := response.Header.Get("X-Scout-SHA256"); expected != "" && digest != expected {
|
fmt.Printf("上传成功:%s %s\nSHA-256:%s\n", item.Name, item.Version, item.SHA256)
|
||||||
_ = os.Remove(*output)
|
|
||||||
fatal("包文件校验失败,文件已删除")
|
|
||||||
}
|
|
||||||
fmt.Printf("下载完成:%s\nSHA-256:%s\n", *output, digest)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func watchCommand(args []string) {
|
func watchCommand(args []string) {
|
||||||
@ -111,102 +115,34 @@ func watchCommand(args []string) {
|
|||||||
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
||||||
packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔")
|
packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔")
|
||||||
flags.Parse(args)
|
flags.Parse(args)
|
||||||
url := strings.TrimRight(*server, "/") + "/api/v1/updates/stream"
|
api := newClient(*server, "")
|
||||||
if *packages != "" {
|
updates, err := api.WatchUpdates(context.Background(), splitPackages(*packages)...)
|
||||||
url += "?packages=" + *packages
|
|
||||||
}
|
|
||||||
response, err := http.Get(url)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal("连接更新通知失败: %v", err)
|
fatal("连接更新通知失败: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
if response.StatusCode != http.StatusOK {
|
|
||||||
fatalResponse(response)
|
|
||||||
}
|
|
||||||
fmt.Println("已连接更新通知,等待新包……")
|
fmt.Println("已连接更新通知,等待新包……")
|
||||||
readSSE(response.Body)
|
for update := range updates {
|
||||||
|
fmt.Printf("收到更新:%s %s\n", update.Package.Name, update.Package.Version)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestJSON(method, url string, body io.Reader, token string, target any) {
|
func newClient(server, token string) *client.Client {
|
||||||
request, err := http.NewRequest(method, url, body)
|
api, err := client.New(server, client.WithDeveloperToken(token))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal("创建请求失败: %v", err)
|
fatal("创建客户端失败: %v", err)
|
||||||
}
|
|
||||||
if token != "" {
|
|
||||||
request.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
response, err := http.DefaultClient.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
fatal("请求失败: %v", err)
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
||||||
fatalResponse(response)
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
|
|
||||||
fatal("解析服务端响应失败: %v", err)
|
|
||||||
}
|
}
|
||||||
|
return api
|
||||||
}
|
}
|
||||||
|
|
||||||
func readSSE(reader io.Reader) {
|
func splitPackages(value string) []string {
|
||||||
dataLines := make([]string, 0, 1)
|
if strings.TrimSpace(value) == "" {
|
||||||
buffer := make([]byte, 4096)
|
return nil
|
||||||
pending := ""
|
|
||||||
for {
|
|
||||||
count, err := reader.Read(buffer)
|
|
||||||
if count > 0 {
|
|
||||||
pending += string(buffer[:count])
|
|
||||||
for {
|
|
||||||
lineEnd := strings.IndexByte(pending, '\n')
|
|
||||||
if lineEnd < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
line := strings.TrimSuffix(pending[:lineEnd], "\r")
|
|
||||||
pending = pending[lineEnd+1:]
|
|
||||||
if line == "" {
|
|
||||||
if len(dataLines) > 0 {
|
|
||||||
var event updateEvent
|
|
||||||
if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &event) == nil {
|
|
||||||
fmt.Printf("收到更新:%s %s\n", event.Package.Name, event.Package.Version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataLines = dataLines[:0]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(line, "data: ") {
|
|
||||||
dataLines = append(dataLines, strings.TrimPrefix(line, "data: "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
fatal("读取更新通知失败: %v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
return strings.Split(value, ",")
|
||||||
|
|
||||||
func fileSHA256(path string) (string, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
hash := sha256.New()
|
|
||||||
if _, err := io.Copy(hash, file); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fatalResponse(response *http.Response) {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
||||||
fatal("服务端返回 %s:%s", response.Status, strings.TrimSpace(string(body)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func requirePackage(name string) {
|
func requirePackage(name string) {
|
||||||
if name == "" {
|
if strings.TrimSpace(name) == "" {
|
||||||
fatal("必须指定 --package")
|
fatal("必须指定 --package")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -218,5 +154,8 @@ func fatal(format string, args ...any) {
|
|||||||
|
|
||||||
func printUsage() {
|
func printUsage() {
|
||||||
fmt.Println("Scout 轻量化包管理客户端")
|
fmt.Println("Scout 轻量化包管理客户端")
|
||||||
fmt.Println("用法:scout-client <check|download|watch> [参数]")
|
fmt.Println("用法:scout-client <check|download|upload|watch> [参数]")
|
||||||
|
fmt.Println("upload 用于开发者上传,check、download、watch 用于客户端更新流程")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ io.Reader
|
||||||
|
|||||||
382
pkg/client/client.go
Normal file
382
pkg/client/client.go
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Package struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
Architecture string `json:"architecture,omitempty"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
UploadedAt string `json:"uploaded_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Update struct {
|
||||||
|
Package Package `json:"package"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadRequest struct {
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Description string
|
||||||
|
Platform string
|
||||||
|
Architecture string
|
||||||
|
FileName string
|
||||||
|
File io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Client)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(baseURL string, options ...Option) (*Client, error) {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil, errors.New("服务端地址不能为空")
|
||||||
|
}
|
||||||
|
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("服务端地址无效: %w", err)
|
||||||
|
}
|
||||||
|
client := &Client{baseURL: baseURL, httpClient: http.DefaultClient}
|
||||||
|
for _, option := range options {
|
||||||
|
option(client)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithHTTPClient(httpClient *http.Client) Option {
|
||||||
|
return func(client *Client) {
|
||||||
|
if httpClient != nil {
|
||||||
|
client.httpClient = httpClient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithDeveloperToken(token string) Option {
|
||||||
|
return func(client *Client) {
|
||||||
|
client.token = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
|
||||||
|
var response struct {
|
||||||
|
Packages []Package `json:"packages"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/api/v1/packages", &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response.Packages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetLatest(ctx context.Context, name string) (Package, error) {
|
||||||
|
var item Package
|
||||||
|
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name), &item); err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListVersions(ctx context.Context, name string) ([]Package, error) {
|
||||||
|
var response struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Versions []Package `json:"versions"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name)+"/versions", &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response.Versions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CheckUpdate(ctx context.Context, name, currentVersion string) (Package, bool, error) {
|
||||||
|
latest, err := c.GetLatest(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, false, err
|
||||||
|
}
|
||||||
|
return latest, CompareVersions(latest.Version, currentVersion) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Download(ctx context.Context, name, version, destination string) (Package, error) {
|
||||||
|
if version == "" {
|
||||||
|
latest, err := c.GetLatest(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
|
version = latest.Version
|
||||||
|
}
|
||||||
|
path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
|
||||||
|
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
|
response, err := c.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, fmt.Errorf("请求下载失败: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if err := checkResponse(response); err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
|
if destination == "" {
|
||||||
|
return Package{}, errors.New("保存路径不能为空")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||||
|
return Package{}, fmt.Errorf("创建保存目录失败: %w", err)
|
||||||
|
}
|
||||||
|
file, err := os.Create(destination)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, fmt.Errorf("创建保存文件失败: %w", err)
|
||||||
|
}
|
||||||
|
hash := sha256.New()
|
||||||
|
_, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body)
|
||||||
|
closeErr := file.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return Package{}, fmt.Errorf("保存包文件失败: %w", copyErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return Package{}, fmt.Errorf("关闭保存文件失败: %w", closeErr)
|
||||||
|
}
|
||||||
|
digest := hex.EncodeToString(hash.Sum(nil))
|
||||||
|
expected := response.Header.Get("X-Scout-SHA256")
|
||||||
|
if expected != "" && !strings.EqualFold(expected, digest) {
|
||||||
|
return Package{}, fmt.Errorf("包文件校验失败: 期望 %s,实际 %s", expected, digest)
|
||||||
|
}
|
||||||
|
return Package{
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
Size: response.ContentLength,
|
||||||
|
SHA256: digest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, error) {
|
||||||
|
if c.token == "" {
|
||||||
|
return Package{}, errors.New("上传包需要开发者令牌")
|
||||||
|
}
|
||||||
|
if input.File == nil {
|
||||||
|
return Package{}, errors.New("包文件不能为空")
|
||||||
|
}
|
||||||
|
bodyReader, bodyWriter := io.Pipe()
|
||||||
|
multipartWriter := multipart.NewWriter(bodyWriter)
|
||||||
|
go func() {
|
||||||
|
err := writeUploadForm(multipartWriter, input)
|
||||||
|
if closeErr := multipartWriter.Close(); err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
_ = bodyWriter.CloseWithError(err)
|
||||||
|
}()
|
||||||
|
request, err := c.newRequest(ctx, http.MethodPost, "/api/v1/packages/upload", bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||||
|
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
response, err := c.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, fmt.Errorf("请求上传失败: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if err := checkResponse(response); err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
|
var item Package
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&item); err != nil {
|
||||||
|
return Package{}, fmt.Errorf("解析上传响应失败: %w", err)
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan Update, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
wanted := make([]string, 0, len(packages))
|
||||||
|
for _, name := range packages {
|
||||||
|
if strings.TrimSpace(name) != "" {
|
||||||
|
wanted = append(wanted, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(wanted) > 0 {
|
||||||
|
query.Set("packages", strings.Join(wanted, ","))
|
||||||
|
}
|
||||||
|
path := "/api/v1/updates/stream"
|
||||||
|
if encoded := query.Encode(); encoded != "" {
|
||||||
|
path += "?" + encoded
|
||||||
|
}
|
||||||
|
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
response, err := c.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("连接更新通知失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := checkResponse(response); err != nil {
|
||||||
|
response.Body.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updates := make(chan Update)
|
||||||
|
go readUpdates(ctx, response.Body, updates)
|
||||||
|
return updates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||||
|
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response, err := c.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if err := checkResponse(response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
|
||||||
|
return fmt.Errorf("解析服务端响应失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
request.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUploadForm(writer *multipart.Writer, input UploadRequest) error {
|
||||||
|
fields := map[string]string{
|
||||||
|
"name": input.Name,
|
||||||
|
"version": input.Version,
|
||||||
|
"description": input.Description,
|
||||||
|
"platform": input.Platform,
|
||||||
|
"architecture": input.Architecture,
|
||||||
|
}
|
||||||
|
for name, value := range fields {
|
||||||
|
if err := writer.WriteField(name, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part, err := writer.CreateFormFile("file", input.FileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(part, input.File)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkResponse(response *http.Response) error {
|
||||||
|
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||||
|
message := strings.TrimSpace(string(body))
|
||||||
|
if message == "" {
|
||||||
|
message = response.Status
|
||||||
|
}
|
||||||
|
return fmt.Errorf("服务端返回 %s: %s", response.Status, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUpdates(ctx context.Context, body io.ReadCloser, updates chan<- Update) {
|
||||||
|
defer close(updates)
|
||||||
|
defer body.Close()
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = body.Close()
|
||||||
|
}()
|
||||||
|
scanner := bufio.NewScanner(body)
|
||||||
|
scanner.Buffer(make([]byte, 4096), 4<<20)
|
||||||
|
dataLines := make([]string, 0, 1)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSuffix(scanner.Text(), "\r")
|
||||||
|
if line == "" {
|
||||||
|
if len(dataLines) > 0 {
|
||||||
|
var update Update
|
||||||
|
if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &update) == nil {
|
||||||
|
select {
|
||||||
|
case updates <- update:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataLines = dataLines[:0]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "data:") {
|
||||||
|
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompareVersions(left, right string) int {
|
||||||
|
leftParts := splitVersion(left)
|
||||||
|
rightParts := splitVersion(right)
|
||||||
|
for index := 0; index < len(leftParts) || index < len(rightParts); index++ {
|
||||||
|
if index >= len(leftParts) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if index >= len(rightParts) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
leftNumber, leftOK := numberPart(leftParts[index])
|
||||||
|
rightNumber, rightOK := numberPart(rightParts[index])
|
||||||
|
if leftOK && rightOK && leftNumber != rightNumber {
|
||||||
|
if leftNumber > rightNumber {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if leftParts[index] != rightParts[index] {
|
||||||
|
return strings.Compare(leftParts[index], rightParts[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitVersion(version string) []string {
|
||||||
|
version = strings.TrimPrefix(version, "v")
|
||||||
|
version = strings.SplitN(version, "+", 2)[0]
|
||||||
|
parts := strings.FieldsFunc(version, func(char rune) bool {
|
||||||
|
return char == '.' || char == '-' || char == '_'
|
||||||
|
})
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberPart(value string) (int64, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var number int64
|
||||||
|
for _, char := range value {
|
||||||
|
if char < '0' || char > '9' {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
number = number*10 + int64(char-'0')
|
||||||
|
}
|
||||||
|
return number, true
|
||||||
|
}
|
||||||
116
pkg/client/client_test.go
Normal file
116
pkg/client/client_test.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"scout/internal/httpapi"
|
||||||
|
"scout/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientUploadQueryCheckAndDownload(t *testing.T) {
|
||||||
|
store, err := registry.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer((&httpapi.Server{Registry: store, DeveloperToken: "测试令牌"}).Handler())
|
||||||
|
defer server.Close()
|
||||||
|
api, err := New(server.URL, WithDeveloperToken("测试令牌"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
uploaded, err := api.Upload(ctx, UploadRequest{
|
||||||
|
Name: "client-demo",
|
||||||
|
Version: "1.0.0",
|
||||||
|
FileName: "demo.pkg",
|
||||||
|
File: bytes.NewBufferString("client package"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if uploaded.SHA256 == "" {
|
||||||
|
t.Fatal("上传结果缺少 SHA-256")
|
||||||
|
}
|
||||||
|
packages, err := api.ListPackages(ctx)
|
||||||
|
if err != nil || len(packages) != 1 {
|
||||||
|
t.Fatalf("包列表错误: %v, %d", err, len(packages))
|
||||||
|
}
|
||||||
|
latest, available, err := api.CheckUpdate(ctx, "client-demo", "0.9.0")
|
||||||
|
if err != nil || !available || latest.Version != "1.0.0" {
|
||||||
|
t.Fatalf("更新检查错误: %+v, %t, %v", latest, available, err)
|
||||||
|
}
|
||||||
|
versions, err := api.ListVersions(ctx, "client-demo")
|
||||||
|
if err != nil || len(versions) != 1 {
|
||||||
|
t.Fatalf("版本列表错误: %v, %d", err, len(versions))
|
||||||
|
}
|
||||||
|
output := filepath.Join(t.TempDir(), "download.pkg")
|
||||||
|
downloaded, err := api.Download(ctx, "client-demo", "", output)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if downloaded.SHA256 != uploaded.SHA256 {
|
||||||
|
t.Fatalf("下载校验值不一致: %s != %s", downloaded.SHA256, uploaded.SHA256)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(output)
|
||||||
|
if err != nil || string(content) != "client package" {
|
||||||
|
t.Fatalf("下载内容错误: %q, %v", content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientWatchUpdates(t *testing.T) {
|
||||||
|
store, err := registry.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer((&httpapi.Server{Registry: store, DeveloperToken: "测试令牌"}).Handler())
|
||||||
|
defer server.Close()
|
||||||
|
api, err := New(server.URL, WithDeveloperToken("测试令牌"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
updates, err := api.WatchUpdates(ctx, "client-demo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
_, _ = api.Upload(context.Background(), UploadRequest{
|
||||||
|
Name: "client-demo",
|
||||||
|
Version: "2.0.0",
|
||||||
|
FileName: "demo.pkg",
|
||||||
|
File: bytes.NewBufferString("new package"),
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case update := <-updates:
|
||||||
|
if update.Package.Name != "client-demo" || update.Package.Version != "2.0.0" {
|
||||||
|
t.Fatalf("通知内容错误: %+v", update)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("等待更新通知超时")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareVersions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{left: "1.10.0", right: "1.9.0", want: 1},
|
||||||
|
{left: "v1.0.0", right: "1.0.0", want: 0},
|
||||||
|
{left: "1.0.0", right: "2.0.0", want: -1},
|
||||||
|
}
|
||||||
|
for _, item := range cases {
|
||||||
|
if got := CompareVersions(item.left, item.right); got != item.want {
|
||||||
|
t.Fatalf("CompareVersions(%q, %q) = %d, want %d", item.left, item.right, got, item.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user