新增可复用Go客户端模块
This commit is contained in:
parent
8c1da695cd
commit
99342696ea
@ -1,32 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
@ -37,6 +22,8 @@ func main() {
|
||||
checkCommand(os.Args[2:])
|
||||
case "download":
|
||||
downloadCommand(os.Args[2:])
|
||||
case "upload":
|
||||
uploadCommand(os.Args[2:])
|
||||
case "watch":
|
||||
watchCommand(os.Args[2:])
|
||||
default:
|
||||
@ -52,9 +39,12 @@ func checkCommand(args []string) {
|
||||
current := flags.String("current", "", "当前版本")
|
||||
flags.Parse(args)
|
||||
requirePackage(*name)
|
||||
var latest packageInfo
|
||||
requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest)
|
||||
if registry.CompareVersions(latest.Version, *current) > 0 {
|
||||
api := newClient(*server, "")
|
||||
latest, available, err := api.CheckUpdate(context.Background(), *name, *current)
|
||||
if err != nil {
|
||||
fatal("检查更新失败: %v", err)
|
||||
}
|
||||
if available {
|
||||
fmt.Printf("发现更新:%s %s -> %s\n", latest.Name, *current, latest.Version)
|
||||
return
|
||||
}
|
||||
@ -69,41 +59,55 @@ func downloadCommand(args []string) {
|
||||
output := flags.String("output", "", "保存路径")
|
||||
flags.Parse(args)
|
||||
requirePackage(*name)
|
||||
if *version == "" {
|
||||
var latest packageInfo
|
||||
requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest)
|
||||
*version = latest.Version
|
||||
}
|
||||
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)
|
||||
response, err := http.Get(url)
|
||||
api := newClient(*server, "")
|
||||
item, err := api.Download(context.Background(), *name, *version, *output)
|
||||
if err != nil {
|
||||
fatal("请求下载失败: %v", err)
|
||||
fatal("下载失败: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
fatalResponse(response)
|
||||
fmt.Printf("下载完成:%s\n版本:%s\nSHA-256:%s\n", *output, item.Version, item.SHA256)
|
||||
}
|
||||
|
||||
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 {
|
||||
fatal("创建输出文件失败: %v", err)
|
||||
fatal("打开包文件失败: %v", err)
|
||||
}
|
||||
_, copyErr := io.Copy(file, response.Body)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
fatal("保存包文件失败")
|
||||
}
|
||||
digest, err := fileSHA256(*output)
|
||||
defer file.Close()
|
||||
api := newClient(*server, *token)
|
||||
item, err := api.Upload(context.Background(), client.UploadRequest{
|
||||
Name: *name,
|
||||
Version: *version,
|
||||
Description: *description,
|
||||
Platform: *platform,
|
||||
Architecture: *architecture,
|
||||
FileName: filepath.Base(*filePath),
|
||||
File: file,
|
||||
})
|
||||
if err != nil {
|
||||
fatal("校验包文件失败: %v", err)
|
||||
fatal("上传失败: %v", err)
|
||||
}
|
||||
if expected := response.Header.Get("X-Scout-SHA256"); expected != "" && digest != expected {
|
||||
_ = os.Remove(*output)
|
||||
fatal("包文件校验失败,文件已删除")
|
||||
}
|
||||
fmt.Printf("下载完成:%s\nSHA-256:%s\n", *output, digest)
|
||||
fmt.Printf("上传成功:%s %s\nSHA-256:%s\n", item.Name, item.Version, item.SHA256)
|
||||
}
|
||||
|
||||
func watchCommand(args []string) {
|
||||
@ -111,102 +115,34 @@ func watchCommand(args []string) {
|
||||
server := flags.String("server", "http://localhost:8080", "服务端地址")
|
||||
packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔")
|
||||
flags.Parse(args)
|
||||
url := strings.TrimRight(*server, "/") + "/api/v1/updates/stream"
|
||||
if *packages != "" {
|
||||
url += "?packages=" + *packages
|
||||
}
|
||||
response, err := http.Get(url)
|
||||
api := newClient(*server, "")
|
||||
updates, err := api.WatchUpdates(context.Background(), splitPackages(*packages)...)
|
||||
if err != nil {
|
||||
fatal("连接更新通知失败: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
fatalResponse(response)
|
||||
}
|
||||
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) {
|
||||
request, err := http.NewRequest(method, url, body)
|
||||
func newClient(server, token string) *client.Client {
|
||||
api, err := client.New(server, client.WithDeveloperToken(token))
|
||||
if err != nil {
|
||||
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)
|
||||
fatal("创建客户端失败: %v", err)
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func readSSE(reader io.Reader) {
|
||||
dataLines := make([]string, 0, 1)
|
||||
buffer := make([]byte, 4096)
|
||||
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
|
||||
}
|
||||
func splitPackages(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
return strings.Split(value, ",")
|
||||
}
|
||||
|
||||
func requirePackage(name string) {
|
||||
if name == "" {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
fatal("必须指定 --package")
|
||||
}
|
||||
}
|
||||
@ -218,5 +154,8 @@ func fatal(format string, args ...any) {
|
||||
|
||||
func printUsage() {
|
||||
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