318 lines
8.4 KiB
Go
318 lines
8.4 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
// Package upgrade provides self-update functionality for the DWS CLI
|
|
// using GitHub Releases as the data source.
|
|
package upgrade
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
|
)
|
|
|
|
func init() {
|
|
configmeta.Register(configmeta.ConfigItem{
|
|
Name: "DWS_UPGRADE_URL",
|
|
Category: configmeta.CategoryNetwork,
|
|
Description: "覆盖 GitHub API 地址 (镜像/测试)",
|
|
DefaultValue: "https://api.github.com",
|
|
Example: "https://mirror.example.com/api",
|
|
})
|
|
configmeta.Register(configmeta.ConfigItem{
|
|
Name: "GITHUB_TOKEN",
|
|
Category: configmeta.CategoryExternal,
|
|
Description: "GitHub API Token (提升 API 限额)",
|
|
Sensitive: true,
|
|
})
|
|
configmeta.Register(configmeta.ConfigItem{
|
|
Name: "GH_TOKEN",
|
|
Category: configmeta.CategoryExternal,
|
|
Description: "GitHub API Token 备选 (GITHUB_TOKEN 为空时使用)",
|
|
Sensitive: true,
|
|
})
|
|
}
|
|
|
|
const (
|
|
gitHubAPIBase = "https://api.github.com"
|
|
defaultOwner = "DingTalk-Real-AI"
|
|
defaultRepo = "dingtalk-workspace-cli"
|
|
httpTimeout = 30 * time.Second
|
|
userAgent = "DWS-CLI-Upgrade/1.0"
|
|
skillsZipName = "dws-skills.zip"
|
|
checksumsName = "checksums.txt"
|
|
)
|
|
|
|
// GitHubRelease represents a single release from the GitHub Releases API.
|
|
type GitHubRelease struct {
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
Body string `json:"body"`
|
|
Prerelease bool `json:"prerelease"`
|
|
Draft bool `json:"draft"`
|
|
PublishedAt string `json:"published_at"`
|
|
Assets []GitHubAsset `json:"assets"`
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
|
|
// GitHubAsset represents a release asset (downloadable file).
|
|
type GitHubAsset struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
Digest string `json:"digest"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
ContentType string `json:"content_type"`
|
|
}
|
|
|
|
// ReleaseInfo is the simplified view of a release used throughout the upgrade flow.
|
|
type ReleaseInfo struct {
|
|
Version string
|
|
Date string
|
|
Changelog string
|
|
Prerelease bool
|
|
HTMLURL string
|
|
Assets []GitHubAsset
|
|
}
|
|
|
|
// VersionEntry represents a single version in the version list.
|
|
type VersionEntry struct {
|
|
Version string
|
|
Date string
|
|
Changelog string
|
|
Prerelease bool
|
|
}
|
|
|
|
// Client communicates with the GitHub Releases API.
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
owner string
|
|
repo string
|
|
baseURL string // overridable for testing or mirrors
|
|
}
|
|
|
|
// NewClient creates a GitHub release client with default settings.
|
|
func NewClient() *Client {
|
|
baseURL := gitHubAPIBase
|
|
if env := os.Getenv("DWS_UPGRADE_URL"); env != "" {
|
|
baseURL = strings.TrimRight(env, "/")
|
|
}
|
|
return &Client{
|
|
httpClient: &http.Client{Timeout: httpTimeout},
|
|
owner: defaultOwner,
|
|
repo: defaultRepo,
|
|
baseURL: baseURL,
|
|
}
|
|
}
|
|
|
|
// NewClientWithBaseURL creates a client with a custom base URL (for testing).
|
|
func NewClientWithBaseURL(baseURL string) *Client {
|
|
return &Client{
|
|
httpClient: &http.Client{Timeout: httpTimeout},
|
|
owner: defaultOwner,
|
|
repo: defaultRepo,
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
}
|
|
}
|
|
|
|
// FetchLatestRelease returns the latest non-draft release.
|
|
func (c *Client) FetchLatestRelease() (*ReleaseInfo, error) {
|
|
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", c.baseURL, c.owner, c.repo)
|
|
|
|
var gh GitHubRelease
|
|
if err := c.getJSON(url, &gh); err != nil {
|
|
return nil, fmt.Errorf("获取最新版本失败: %w", err)
|
|
}
|
|
|
|
return ghReleaseToInfo(&gh), nil
|
|
}
|
|
|
|
// FetchReleaseByTag returns the release for a specific tag (e.g. "v1.0.5").
|
|
func (c *Client) FetchReleaseByTag(tag string) (*ReleaseInfo, error) {
|
|
if !strings.HasPrefix(tag, "v") {
|
|
tag = "v" + tag
|
|
}
|
|
url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", c.baseURL, c.owner, c.repo, tag)
|
|
|
|
var gh GitHubRelease
|
|
if err := c.getJSON(url, &gh); err != nil {
|
|
return nil, fmt.Errorf("获取版本 %s 失败: %w", tag, err)
|
|
}
|
|
|
|
return ghReleaseToInfo(&gh), nil
|
|
}
|
|
|
|
// FetchAllReleases returns all non-draft releases, newest first.
|
|
func (c *Client) FetchAllReleases() ([]VersionEntry, error) {
|
|
url := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=100", c.baseURL, c.owner, c.repo)
|
|
|
|
var ghReleases []GitHubRelease
|
|
if err := c.getJSON(url, &ghReleases); err != nil {
|
|
return nil, fmt.Errorf("获取版本列表失败: %w", err)
|
|
}
|
|
|
|
var versions []VersionEntry
|
|
for _, gh := range ghReleases {
|
|
if gh.Draft {
|
|
continue
|
|
}
|
|
versions = append(versions, VersionEntry{
|
|
Version: stripV(gh.TagName),
|
|
Date: formatDate(gh.PublishedAt),
|
|
Changelog: gh.Body,
|
|
Prerelease: gh.Prerelease,
|
|
})
|
|
}
|
|
return versions, nil
|
|
}
|
|
|
|
// FindBinaryAsset locates the platform-specific binary archive from the release assets.
|
|
// Pattern: dws-{os}-{arch}.tar.gz (or .zip for windows).
|
|
func FindBinaryAsset(assets []GitHubAsset) (*GitHubAsset, error) {
|
|
return FindBinaryAssetFor(assets, runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
|
|
// FindBinaryAssetFor locates the binary archive for a specific platform.
|
|
func FindBinaryAssetFor(assets []GitHubAsset, goos, goarch string) (*GitHubAsset, error) {
|
|
ext := ".tar.gz"
|
|
if goos == "windows" {
|
|
ext = ".zip"
|
|
}
|
|
target := fmt.Sprintf("dws-%s-%s%s", goos, goarch, ext)
|
|
|
|
for i := range assets {
|
|
if assets[i].Name == target {
|
|
return &assets[i], nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("当前平台 %s/%s 没有可用的预编译二进制 (需要 %s)", goos, goarch, target)
|
|
}
|
|
|
|
// FindSkillsAsset locates the dws-skills.zip asset.
|
|
func FindSkillsAsset(assets []GitHubAsset) *GitHubAsset {
|
|
for i := range assets {
|
|
if assets[i].Name == skillsZipName {
|
|
return &assets[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindChecksumsAsset locates the checksums.txt asset.
|
|
func FindChecksumsAsset(assets []GitHubAsset) *GitHubAsset {
|
|
for i := range assets {
|
|
if assets[i].Name == checksumsName {
|
|
return &assets[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExtractDigestSHA256 extracts the hex hash from a GitHub asset digest field.
|
|
// GitHub format: "sha256:abcdef1234..."
|
|
func ExtractDigestSHA256(digest string) string {
|
|
if strings.HasPrefix(digest, "sha256:") {
|
|
return digest[7:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getJSON performs a GET request and decodes the JSON response.
|
|
func (c *Client) getJSON(url string, target interface{}) error {
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("User-Agent", userAgent)
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
|
|
if token := githubToken(); token != "" {
|
|
req.Header.Set("Authorization", "token "+token)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("无法连接到 GitHub: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining == "0" {
|
|
return fmt.Errorf("GitHub API 请求频率超限。设置 GITHUB_TOKEN 或 GH_TOKEN 环境变量可提升限额")
|
|
}
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return fmt.Errorf("未找到 (404)")
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("GitHub API 返回 HTTP %d: %s", resp.StatusCode, truncateBytes(body, 200))
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
|
|
return fmt.Errorf("解析 GitHub 响应失败: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// githubToken returns a GitHub token from environment if available.
|
|
func githubToken() string {
|
|
if t := os.Getenv("GITHUB_TOKEN"); t != "" {
|
|
return t
|
|
}
|
|
if t := os.Getenv("GH_TOKEN"); t != "" {
|
|
return t
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func ghReleaseToInfo(gh *GitHubRelease) *ReleaseInfo {
|
|
return &ReleaseInfo{
|
|
Version: stripV(gh.TagName),
|
|
Date: formatDate(gh.PublishedAt),
|
|
Changelog: gh.Body,
|
|
Prerelease: gh.Prerelease,
|
|
HTMLURL: gh.HTMLURL,
|
|
Assets: gh.Assets,
|
|
}
|
|
}
|
|
|
|
func stripV(tag string) string {
|
|
return strings.TrimPrefix(tag, "v")
|
|
}
|
|
|
|
func formatDate(published string) string {
|
|
t, err := time.Parse(time.RFC3339, published)
|
|
if err != nil {
|
|
return published
|
|
}
|
|
return t.Format("2006-01-02")
|
|
}
|
|
|
|
func truncateBody(body string, maxLen int) string {
|
|
body = strings.TrimSpace(body)
|
|
lines := strings.SplitN(body, "\n", 2)
|
|
first := lines[0]
|
|
if len(first) > maxLen {
|
|
return first[:maxLen-3] + "..."
|
|
}
|
|
return first
|
|
}
|
|
|
|
func truncateBytes(b []byte, max int) string {
|
|
if len(b) > max {
|
|
return string(b[:max]) + "..."
|
|
}
|
|
return string(b)
|
|
}
|