开发检查点:完善多项目客户端与管理能力

This commit is contained in:
Scout开发者 2026-08-31 19:19:11 +08:00
parent 82bf06eca6
commit 026a756a53
11 changed files with 397 additions and 38 deletions

View File

@ -17,12 +17,8 @@ func main() {
if server == "" {
server = "http://127.0.0.1:8080"
}
api, err := client.New(server, client.WithSessionToken(os.Getenv("SCOUT_SESSION")))
if err != nil {
fail(err)
}
manager, err := updater.New(updater.Config{
project := os.Getenv("SCOUT_PROJECT") var api *client.Client var err error if project == "" || project == "default" { api, err = client.New(server, client.WithSessionToken(os.Getenv("SCOUT_SESSION"))) } else { api, err = client.NewProject(server, project, client.WithSessionToken(os.Getenv("SCOUT_SESSION"))) } if err != nil { fail(err) }
manager, err := updater.New(updater.Config{
Client: api,
PackageName: "demo-core",
CurrentVersion: "1.0.0",

View File

@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
const defaultApi = import.meta.env.VITE_API_BASE || (['5173', '5174'].includes(window.location.port) ? 'http://127.0.0.1:8080' : window.location.origin)
const sessionKey = 'scout-session'
const projectKey = 'scout-project'
export function useHome() {
const apiBase = ref(defaultApi)
@ -12,6 +13,8 @@ export function useHome() {
const authForm = ref({ email: '', name: '', password: '' })
const resetForm = ref({ email: '', token: '', password: '' })
const adminUsers = ref([])
const projects = ref([])
const selectedProject = ref(localStorage.getItem(projectKey) || 'default')
const search = ref('')
const filters = ref({ platform: '', architecture: '', channel: '', currentVersion: '' })
const packages = ref([])
@ -66,6 +69,36 @@ export function useHome() {
return session.value ? { Authorization: `Bearer ${session.value}` } : {}
}
function resourcePath(path) {
if (selectedProject.value === 'default' || !path.startsWith('/api/v1')) return path
return `/api/v1/projects/${encodeURIComponent(selectedProject.value)}${path.slice('/api/v1'.length)}`
}
async function loadProjects() {
try {
const payload = await request('/api/v1/projects')
projects.value = payload.projects || []
const selected = projects.value.find((item) => item.id === selectedProject.value)
if (!selected || selected.status !== 'active') {
selectedProject.value = 'default'
localStorage.setItem(projectKey, selectedProject.value)
}
} catch (reason) {
error.value = reason.message
}
}
async function changeProject() {
const selected = projects.value.find((item) => item.id === selectedProject.value)
if (!selected || selected.status !== 'active') {
selectedProject.value = 'default'
}
localStorage.setItem(projectKey, selectedProject.value)
selectedPackage.value = null
versions.value = []
await refreshPackages()
}
async function readResponse(response) {
const contentType = response.headers.get('content-type') || ''
const payload = contentType.includes('application/json')
@ -94,7 +127,7 @@ export function useHome() {
if (filters.value.channel) params.set('channel', filters.value.channel)
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
const query = params.toString() ? `?${params.toString()}` : ''
const payload = await request(`/api/v1/packages${query}`)
const payload = await request(`${resourcePath('/api/v1/packages')}${query}`)
packages.value = payload.packages || []
if (selectedPackage.value) {
const latest = packages.value.find((item) => item.name === selectedPackage.value.name)
@ -156,7 +189,7 @@ export function useHome() {
if (filters.value.channel) params.set('channel', filters.value.channel)
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
const query = params.toString() ? `?${params.toString()}` : ''
const payload = await request(`/api/v1/packages/${encodeURIComponent(item.name)}/versions${query}`)
const payload = await request(`${resourcePath(`/api/v1/packages/${encodeURIComponent(item.name)}/versions`)}${query}`)
versions.value = payload.versions || []
} catch (reason) {
error.value = reason.message
@ -194,7 +227,7 @@ export function useHome() {
const headers = session.value
? authHeaders()
: { Authorization: `Bearer ${developerToken.value}` }
const payload = await request('/api/v1/packages/upload', {
const payload = await request(resourcePath('/api/v1/packages/upload'), {
method: 'POST',
headers,
body: formData,
@ -329,7 +362,7 @@ export function useHome() {
if (filters.value.architecture) params.set('architecture', filters.value.architecture)
if (filters.value.currentVersion) params.set('current_version', filters.value.currentVersion)
const query = params.toString() ? `?${params.toString()}` : ''
return endpoint(`/api/v1/packages/${encodeURIComponent(item.name)}/${encodeURIComponent(item.version)}/download${query}`)
return endpoint(`${resourcePath(`/api/v1/packages/${encodeURIComponent(item.name)}/${encodeURIComponent(item.version)}/download`)}${query}`)
}
function startNotifications() {
@ -343,7 +376,7 @@ export function useHome() {
params.set('versions', `${selectedPackage.value.name}:${filters.value.currentVersion}`)
}
const query = params.toString() ? `?${params.toString()}` : ''
eventSource = new EventSource(endpoint(`/api/v1/updates/stream${query}`))
eventSource = new EventSource(endpoint(`${resourcePath('/api/v1/updates/stream')}${query}`))
eventSource.addEventListener('package_updated', async (event) => {
const payload = JSON.parse(event.data)
message.value = `收到更新通知:${payload.package.name} ${payload.package.version}`
@ -372,6 +405,8 @@ export function useHome() {
authForm,
resetForm,
adminUsers,
projects,
selectedProject,
search,
filters,
packages,
@ -392,6 +427,8 @@ export function useHome() {
uploadArchitectureOptions,
channelOptions,
roleText,
loadProjects,
changeProject,
loadPackages,
refreshPackages,
scheduleRefresh,

View File

@ -11,6 +11,8 @@ const {
authForm,
resetForm,
adminUsers,
projects,
selectedProject,
search,
filters,
packages,
@ -31,6 +33,8 @@ const {
uploadArchitectureOptions,
channelOptions,
roleText,
loadProjects,
changeProject,
loadPackages,
refreshPackages,
scheduleRefresh,
@ -53,6 +57,7 @@ const {
onMounted(async () => {
await loadMe()
await loadProjects()
await refreshPackages()
})
@ -79,6 +84,14 @@ onUnmounted(stopNotifications)
<span>服务端地址</span>
<input v-model="apiBase" type="url" placeholder="http://127.0.0.1:8080" />
</label>
<label>
<span>发布项目</span>
<select v-model="selectedProject" @change="changeProject">
<option v-for="item in projects" :key="item.id" :value="item.id" :disabled="item.status !== 'active'">
{{ item.name }}({{ item.id }}{{ item.status === 'active' ? '' : ',已停用' }})
</option>
</select>
</label>
<label>
<span>搜索包</span>
<input v-model="search" type="search" placeholder="输入包名或描述" @keyup.enter="loadPackages" />

View File

@ -15,6 +15,12 @@ type projectCreateRequest struct {
Description string `json:"description"`
}
type projectUpdateRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Status *string `json:"status"`
}
func (s *Server) projectList(writer http.ResponseWriter, _ *http.Request) {
projects := make([]registry.Project, 0, 1)
projects = append(projects, registry.Project{
@ -83,11 +89,43 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
return
}
if len(parts) == 1 {
if request.Method != http.MethodGet {
writeError(writer, http.StatusMethodNotAllowed, "请求方法不支持")
if request.Method == http.MethodGet {
writeJSON(writer, http.StatusOK, project)
return
}
writeJSON(writer, http.StatusOK, project)
if request.Method == http.MethodPatch {
if !s.requireRole(writer, request, users.RoleAdmin) {
return
}
var input projectUpdateRequest
if err := decodeJSON(request, &input); err != nil {
writeError(writer, http.StatusBadRequest, err.Error())
return
}
update := registry.Project{
Name: project.Name,
Description: project.Description,
Status: project.Status,
}
if input.Name != nil {
update.Name = *input.Name
}
if input.Description != nil {
update.Description = *input.Description
}
if input.Status != nil {
update.Status = *input.Status
}
updated, err := s.Projects.Update(projectID, update)
if err != nil {
writeError(writer, http.StatusBadRequest, err.Error())
return
}
s.sendAdminsEmail("Scout 项目已更新", "项目 "+updated.ID+" 的配置已更新。")
writeJSON(writer, http.StatusOK, updated)
return
}
writeError(writer, http.StatusMethodNotAllowed, "请求方法不支持")
return
}
if project.Status != "active" {
@ -99,9 +137,7 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
writeError(writer, http.StatusNotFound, "项目存储不存在")
return
}
scoped := *s
scoped.Registry = store
scoped.projectScope = projectID
scoped := s.scopedRegistry(store, projectID)
if parts[1] == "packages" {
if len(parts) == 2 && request.Method == http.MethodGet {
scoped.packageList(writer, scopedRequest(request, "/api/v1/packages"))
@ -128,6 +164,25 @@ func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request)
writeError(writer, http.StatusNotFound, "接口不存在")
}
func (s *Server) scopedRegistry(store *registry.Registry, projectID string) *Server {
return &Server{
Registry: store,
Projects: s.Projects,
DeveloperToken: s.DeveloperToken,
MaxUploadBytes: s.MaxUploadBytes,
Users: s.Users,
Mailer: s.Mailer,
SessionDuration: s.SessionDuration,
AllowedOrigins: s.AllowedOrigins,
AllowedHosts: s.AllowedHosts,
ForceHTTPS: s.ForceHTTPS,
TrustProxy: s.TrustProxy,
AllowLegacyToken: s.AllowLegacyToken,
RateLimitPerMinute: s.RateLimitPerMinute,
Logger: s.Logger,
projectScope: projectID,
}
}
func scopedRequest(request *http.Request, path string) *http.Request {
clone := request.Clone(request.Context())
clone.URL.Path = path

View File

@ -115,3 +115,39 @@ func assertProjectPackageContent(t *testing.T, endpoint, expected string) {
t.Fatalf("项目包内容错误:%d %q", response.StatusCode, data)
}
}
func TestProjectRouteRejectsDisabledProject(t *testing.T) {
root := t.TempDir()
legacy, err := registry.New(root)
if err != nil {
t.Fatal(err)
}
projects, err := registry.NewProjectRegistry(root)
if err != nil {
t.Fatal(err)
}
if _, _, err := projects.Ensure("ssl", "SSL", "证书项目"); err != nil {
t.Fatal(err)
}
if _, err := projects.Update("ssl", registry.Project{
Name: "SSL",
Description: "已停用",
Status: "disabled",
}); err != nil {
t.Fatal(err)
}
server := httptest.NewServer((&Server{
Registry: legacy,
Projects: projects,
}).Handler())
defer server.Close()
response, err := http.Get(server.URL + "/api/v1/projects/ssl/packages")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("停用项目应禁止访问:%d", response.StatusCode)
}
}

View File

@ -111,6 +111,37 @@ func (r *ProjectRegistry) Create(input Project) (Project, error) {
return input, nil
}
func (r *ProjectRegistry) Update(id string, input Project) (Project, error) {
id = strings.TrimSpace(id)
if err := ValidateProjectID(id); err != nil {
return Project{}, err
}
if id == DefaultProject {
return Project{}, errors.New("default 是兼容项目标识,不能修改")
}
if strings.TrimSpace(input.Name) == "" {
return Project{}, errors.New("项目名称不能为空")
}
if input.Status != "active" && input.Status != "disabled" {
return Project{}, errors.New("项目状态只能是 active 或 disabled")
}
r.mu.Lock()
defer r.mu.Unlock()
current, exists := r.projects[id]
if !exists {
return Project{}, errors.New("项目不存在")
}
current.Name = strings.TrimSpace(input.Name)
current.Description = strings.TrimSpace(input.Description)
current.Status = input.Status
current.UpdatedAt = time.Now().UTC()
if err := writeProjectMetadata(filepath.Join(r.root, id), current); err != nil {
return Project{}, err
}
r.projects[id] = current
return current, nil
}
func (r *ProjectRegistry) Ensure(id, name, description string) (Project, *Registry, error) {
id = strings.TrimSpace(id)
if err := ValidateProjectID(id); err != nil {

View File

@ -77,3 +77,34 @@ func TestValidateProjectID(t *testing.T) {
}
}
}
func TestProjectRegistryUpdatePersistsStatus(t *testing.T) {
root := t.TempDir()
projects, err := NewProjectRegistry(root)
if err != nil {
t.Fatal(err)
}
created, err := projects.Create(Project{ID: "ssl", Name: "SSL", Description: "证书项目"})
if err != nil {
t.Fatal(err)
}
updated, err := projects.Update("ssl", Project{
Name: "SSL 发布中心",
Description: "证书项目更新",
Status: "disabled",
})
if err != nil {
t.Fatal(err)
}
if updated.ID != created.ID || updated.Status != "disabled" || updated.Name != "SSL 发布中心" {
t.Fatalf("项目更新结果错误:%+v", updated)
}
loaded, err := NewProjectRegistry(root)
if err != nil {
t.Fatal(err)
}
persisted, ok := loaded.Get("ssl")
if !ok || persisted.Status != "disabled" || persisted.Description != "证书项目更新" {
t.Fatalf("项目更新未持久化:%+v %v", persisted, ok)
}
}

View File

@ -87,6 +87,11 @@ func New(baseURL string, options ...Option) (*Client, error) {
for _, option := range options {
option(client)
}
if client.project != "" {
if err := validateProjectID(client.project); err != nil {
return nil, err
}
}
return client, nil
}
@ -112,18 +117,32 @@ func WithProject(project string) Option {
func NewProject(baseURL, project string, options ...Option) (*Client, error) {
project = strings.TrimSpace(project)
if project == "" {
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u4e0d\u80fd\u4e3a\u7a7a")
}
for _, char := range project {
if !(char == '-' || char == '_' || char == '.' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u5305\u542b\u65e0\u6548\u5b57\u7b26")
}
if err := validateProjectID(project); err != nil {
return nil, err
}
options = append(options, WithProject(project))
return New(baseURL, options...)
}
func validateProjectID(project string) error {
project = strings.TrimSpace(project)
if project == "" {
return errors.New("\u9879\u76ee\u6807\u8bc6\u4e0d\u80fd\u4e3a\u7a7a")
}
if len(project) > 64 {
return errors.New("\u9879\u76ee\u6807\u8bc6\u957f\u5ea6\u4e0d\u80fd\u8d85\u8fc764\u4e2a\u5b57\u7b26")
}
for index, char := range project {
if index == 0 && (char == '.' || char == '-' || char == '_') {
return errors.New("\u9879\u76ee\u6807\u8bc6\u5f00\u5934\u5fc5\u987b\u662f\u5b57\u6bcd\u6216\u6570\u5b57")
}
if !(char == '-' || char == '_' || char == '.' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return errors.New("\u9879\u76ee\u6807\u8bc6\u5305\u542b\u65e0\u6548\u5b57\u7b26")
}
}
return nil
}
func (c *Client) Project() string {
return c.project
}
@ -132,7 +151,11 @@ func (c *Client) resourcePath(path string) string {
if c.project == "" {
return path
}
return "/api/v1/projects/" + url.PathEscape(c.project) + path[len("/api/v1"):]
const apiPrefix = "/api/v1"
if !strings.HasPrefix(path, apiPrefix) {
return path
}
return "/api/v1/projects/" + url.PathEscape(c.project) + path[len(apiPrefix):]
}
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
@ -180,6 +203,9 @@ func (c *Client) Download(ctx context.Context, name, version, destination string
}
version = latest.Version
}
if strings.TrimSpace(destination) == "" {
return Package{}, errors.New("保存路径不能为空")
}
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
@ -193,41 +219,68 @@ func (c *Client) Download(ctx context.Context, name, version, destination string
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)
temporary, err := os.CreateTemp(filepath.Dir(destination), ".scout-download-*")
if err != nil {
return Package{}, fmt.Errorf("创建保存文件失败: %w", err)
return Package{}, fmt.Errorf("创建临时下载文件失败: %w", err)
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body)
closeErr := file.Close()
size, copyErr := io.Copy(io.MultiWriter(temporary, hash), response.Body)
closeErr := temporary.Close()
if copyErr != nil {
return Package{}, fmt.Errorf("保存包文件失败: %w", copyErr)
}
if closeErr != nil {
return Package{}, fmt.Errorf("关闭保存文件失败: %w", closeErr)
return Package{}, fmt.Errorf("关闭临时下载文件失败: %w", closeErr)
}
if response.ContentLength >= 0 && size != response.ContentLength {
return Package{}, fmt.Errorf("包文件大小校验失败: 期望 %d,实际 %d", response.ContentLength, size)
}
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)
}
if err := copyDownloadedFile(temporaryName, destination); err != nil {
return Package{}, err
}
return Package{
Project: c.project,
Name: name,
Version: version,
Size: response.ContentLength,
Size: size,
SHA256: digest,
}, nil
}
func copyDownloadedFile(source, destination string) error {
input, err := os.Open(source)
if err != nil {
return fmt.Errorf("读取临时下载文件失败: %w", err)
}
defer input.Close()
output, err := os.Create(destination)
if err != nil {
return fmt.Errorf("创建保存文件失败: %w", err)
}
_, copyErr := io.Copy(output, input)
closeErr := output.Close()
if copyErr != nil {
return fmt.Errorf("写入保存文件失败: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("关闭保存文件失败: %w", closeErr)
}
return nil
}
func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, error) {
if c.token == "" {
return Package{}, errors.New("上传包需要开发者令牌")
return Package{}, errors.New("上传包需要开发者或管理员认证令牌")
}
if input.File == nil {
return Package{}, errors.New("包文件不能为空")
@ -294,6 +347,29 @@ func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan U
return updates, nil
}
func (c *Client) patchJSON(ctx context.Context, path string, value any, target any) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("编码请求失败: %w", err)
}
request, err := c.newRequest(ctx, http.MethodPatch, path, bytes.NewReader(data))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
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) getJSON(ctx context.Context, path string, target any) error {
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {

View File

@ -3,6 +3,7 @@ package client
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
@ -118,3 +119,31 @@ func TestCompareVersions(t *testing.T) {
}
}
}
func TestDownloadKeepsDestinationWhenChecksumFails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Length", "7")
writer.Header().Set("X-Scout-SHA256", "invalid")
_, _ = writer.Write([]byte("payload"))
}))
defer server.Close()
api, err := New(server.URL)
if err != nil {
t.Fatal(err)
}
destination := filepath.Join(t.TempDir(), "package.pkg")
if err := os.WriteFile(destination, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := api.Download(context.Background(), "demo", "1.0.0", destination); err == nil {
t.Fatal("错误校验值应该导致下载失败")
}
data, err := os.ReadFile(destination)
if err != nil {
t.Fatal(err)
}
if string(data) != "original" {
t.Fatalf("校验失败不应覆盖原文件:%q", data)
}
}

View File

@ -52,6 +52,22 @@ func (c *Client) CreateProject(ctx context.Context, project Project) (Project, e
return result, nil
}
func (c *Client) UpdateProject(ctx context.Context, project Project) (Project, error) {
project.ID = strings.TrimSpace(project.ID)
if err := validateProjectID(project.ID); err != nil {
return Project{}, err
}
var result Project
path := "/api/v1/projects/" + url.PathEscape(project.ID)
if err := c.patchJSON(ctx, path, map[string]string{
"name": project.Name,
"description": project.Description,
"status": project.Status,
}, &result); err != nil {
return Project{}, err
}
return result, nil
}
func (c *Client) ProjectHealth(ctx context.Context) error {
request, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil)
if err != nil {

View File

@ -151,6 +151,45 @@ Authorization: Bearer <SCOUT_DEVELOPER_TOKEN>
- 包上传成功或失败:通知执行上传的开发者/管理员。
- 普通用户不会因为包版本更新收到邮件,仍通过 SSE 或客户端主动检查获取更新。
## 多项目发布中心
Scout 服务端是独立发布中心,不需要修改 SSL、Smail 或后续接入项目的源代码。每个项目使用独立的包目录、版本索引和 SSE 通知流;项目之间可以存在同名包而不会互相覆盖。
服务端默认保留 `default` 兼容项目,对应原有 `/api/v1/packages` 接口。新增项目使用项目级接口,例如项目标识为 `ssl` 时:
| 方法 | 路径 | 用途 | 认证 |
| --- | --- | --- | --- |
| GET | `/api/v1/projects` | 查看项目列表 | 无 |
| POST | `/api/v1/projects` | 创建项目 | 管理员 |
| GET | `/api/v1/projects/{project}` | 查看项目配置 | 无 |
| PATCH | `/api/v1/projects/{project}` | 修改项目名称、说明或状态 | 管理员 |
| GET | `/api/v1/projects/{project}/packages` | 查看项目包列表 | 无 |
| POST | `/api/v1/projects/{project}/packages/upload` | 上传项目包 | 开发者/管理员会话 |
| GET | `/api/v1/projects/{project}/packages/{name}/{version}/download` | 下载项目包 | 无 |
| GET | `/api/v1/projects/{project}/updates/stream` | 监听项目更新 | 无 |
项目状态为 `disabled` 时,项目配置仍可查询,但包列表、上传、下载和更新通知会被拒绝。项目目录位于:
```text
data/projects/{project}/
```
接入 SSL、Smail 或后续项目时,只需在发布中心创建对应项目,再让宿主应用使用项目级客户端:
```go
api, err := client.NewProject(
"https://packages.example.com",
"ssl",
client.WithSessionToken(session),
)
if err != nil {
return err
}
latest, available, err := api.CheckUpdate(ctx, "scert", currentVersion)
```
客户端不会修改接入项目;项目只通过 Scout API 和 `pkg/client`、`pkg/updater` 模块通信。
## Go 客户端命令
编译命令行客户端:
@ -162,19 +201,19 @@ go build -o .\scout-client.exe .\cmd\scout-client
检查更新:
```powershell
.\scout-client.exe check --server http://127.0.0.1:8080 --package demo-core --current 1.0.0
.\scout-client.exe check --server http://127.0.0.1:8080 --project ssl --package demo-core --current 1.0.0
```
下载最新版本:
```powershell
.\scout-client.exe download --server http://127.0.0.1:8080 --package demo-core --output .\demo-core.pkg
.\scout-client.exe download --server http://127.0.0.1:8080 --project ssl --package demo-core --output .\demo-core.pkg
```
监听更新通知:
```powershell
.\scout-client.exe watch --server http://127.0.0.1:8080 --packages demo-core
.\scout-client.exe watch --server http://127.0.0.1:8080 --project ssl --packages demo-core
```
更新生命周期命令: