package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "net/http" "os" "path/filepath" "strings" "scout/internal/registry" ) 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() os.Exit(2) } switch os.Args[1] { case "check": checkCommand(os.Args[2:]) case "download": downloadCommand(os.Args[2:]) case "watch": watchCommand(os.Args[2:]) default: printUsage() os.Exit(2) } } func checkCommand(args []string) { flags := flag.NewFlagSet("check", flag.ExitOnError) server := flags.String("server", "http://localhost:8080", "服务端地址") name := flags.String("package", "", "包名") 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 { fmt.Printf("发现更新:%s %s -> %s\n", latest.Name, *current, latest.Version) return } fmt.Printf("当前已是最新版本:%s\n", latest.Version) } func downloadCommand(args []string) { flags := flag.NewFlagSet("download", flag.ExitOnError) server := flags.String("server", "http://localhost:8080", "服务端地址") name := flags.String("package", "", "包名") version := flags.String("version", "", "版本;为空时下载最新版本") 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)) } url := fmt.Sprintf("%s/api/v1/packages/%s/%s/download", strings.TrimRight(*server, "/"), *name, *version) response, err := http.Get(url) if err != nil { fatal("请求下载失败: %v", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { fatalResponse(response) } file, err := os.Create(*output) if err != nil { fatal("创建输出文件失败: %v", err) } _, copyErr := io.Copy(file, response.Body) closeErr := file.Close() if copyErr != nil || closeErr != nil { fatal("保存包文件失败") } digest, err := fileSHA256(*output) if err != nil { 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) } func watchCommand(args []string) { flags := flag.NewFlagSet("watch", flag.ExitOnError) 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) if err != nil { fatal("连接更新通知失败: %v", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { fatalResponse(response) } fmt.Println("已连接更新通知,等待新包……") readSSE(response.Body) } func requestJSON(method, url string, body io.Reader, token string, target any) { request, err := http.NewRequest(method, url, body) 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) } } 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 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) { if name == "" { fatal("必须指定 --package") } } func fatal(format string, args ...any) { fmt.Printf("错误:"+format+"\n", args...) os.Exit(1) } func printUsage() { fmt.Println("Scout 轻量化包管理客户端") fmt.Println("用法:scout-client [参数]") }