483 lines
13 KiB
Go
483 lines
13 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"
|
||
"strconv"
|
||
"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: "DWS_UPGRADE_REPOSITORY",
|
||
Category: configmeta.CategoryNetwork,
|
||
Description: "覆盖升级使用的 GitHub 仓库 owner/repo (测试/灰度)",
|
||
DefaultValue: defaultOwner + "/" + defaultRepo,
|
||
Example: "PeterGuy326/dingtalk-workspace-cli",
|
||
})
|
||
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
|
||
}
|
||
|
||
// ReleaseTrack selects which release stream an upgrade operation should use.
|
||
type ReleaseTrack string
|
||
|
||
const (
|
||
ReleaseTrackRelease ReleaseTrack = "release"
|
||
ReleaseTrackBeta ReleaseTrack = "beta"
|
||
ReleaseTrackAll ReleaseTrack = "all"
|
||
)
|
||
|
||
// Client communicates with the GitHub Releases API.
|
||
type Client struct {
|
||
httpClient *http.Client
|
||
owner string
|
||
repo string
|
||
baseURL string // overridable for testing or mirrors
|
||
configErr error
|
||
}
|
||
|
||
// 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, "/")
|
||
}
|
||
owner, repo, configErr := repositoryFromEnv()
|
||
return &Client{
|
||
httpClient: &http.Client{Timeout: httpTimeout},
|
||
owner: owner,
|
||
repo: repo,
|
||
baseURL: baseURL,
|
||
configErr: configErr,
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
if err := c.validateConfig(); err != nil {
|
||
return nil, err
|
||
}
|
||
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
|
||
}
|
||
|
||
// FetchLatestReleaseForTrack returns the latest release in the requested track.
|
||
func (c *Client) FetchLatestReleaseForTrack(track ReleaseTrack) (*ReleaseInfo, error) {
|
||
switch track {
|
||
case ReleaseTrackBeta:
|
||
return c.FetchLatestPrerelease()
|
||
case ReleaseTrackRelease, "":
|
||
return c.FetchLatestStableRelease()
|
||
default:
|
||
return nil, fmt.Errorf("未知升级轨道: %s", track)
|
||
}
|
||
}
|
||
|
||
// FetchLatestStableRelease returns the newest non-draft, non-prerelease release
|
||
// whose tag is a formal semantic version (vX.Y.Z).
|
||
func (c *Client) FetchLatestStableRelease() (*ReleaseInfo, error) {
|
||
releases, err := c.fetchReleases()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取正式 release 版本失败: %w", err)
|
||
}
|
||
for i := range releases {
|
||
if !releaseMatchesTrack(releases[i], ReleaseTrackRelease) {
|
||
continue
|
||
}
|
||
return ghReleaseToInfo(&releases[i]), nil
|
||
}
|
||
return nil, fmt.Errorf("未找到正式 release 版本(需要非 pre-release 且 tag 形如 vX.Y.Z)")
|
||
}
|
||
|
||
// FetchLatestPrerelease returns the newest non-draft prerelease.
|
||
func (c *Client) FetchLatestPrerelease() (*ReleaseInfo, error) {
|
||
releases, err := c.fetchReleases()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 beta 版本失败: %w", err)
|
||
}
|
||
for i := range releases {
|
||
if !releaseMatchesTrack(releases[i], ReleaseTrackBeta) {
|
||
continue
|
||
}
|
||
return ghReleaseToInfo(&releases[i]), nil
|
||
}
|
||
return nil, fmt.Errorf("未找到 beta 版本(需要 GitHub pre-release 且 tag 形如 vX.Y.Z-*)")
|
||
}
|
||
|
||
// FetchReleaseByTag returns the release for a specific tag (e.g. "v1.0.5").
|
||
func (c *Client) FetchReleaseByTag(tag string) (*ReleaseInfo, error) {
|
||
if err := c.validateConfig(); err != nil {
|
||
return nil, err
|
||
}
|
||
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) {
|
||
return c.FetchReleaseVersions(ReleaseTrackAll)
|
||
}
|
||
|
||
// FetchReleaseVersions returns non-draft releases matching the requested track,
|
||
// newest first. The GitHub API already returns releases newest first.
|
||
func (c *Client) FetchReleaseVersions(track ReleaseTrack) ([]VersionEntry, error) {
|
||
ghReleases, err := c.fetchReleases()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取版本列表失败: %w", err)
|
||
}
|
||
|
||
var versions []VersionEntry
|
||
for _, gh := range ghReleases {
|
||
if !releaseMatchesTrack(gh, track) {
|
||
continue
|
||
}
|
||
versions = append(versions, VersionEntry{
|
||
Version: stripV(gh.TagName),
|
||
Date: formatDate(gh.PublishedAt),
|
||
Changelog: gh.Body,
|
||
Prerelease: gh.Prerelease,
|
||
})
|
||
}
|
||
return versions, nil
|
||
}
|
||
|
||
func (c *Client) fetchReleases() ([]GitHubRelease, error) {
|
||
if err := c.validateConfig(); err != nil {
|
||
return nil, err
|
||
}
|
||
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, err
|
||
}
|
||
return ghReleases, nil
|
||
}
|
||
|
||
func releaseMatchesTrack(gh GitHubRelease, track ReleaseTrack) bool {
|
||
if gh.Draft {
|
||
return false
|
||
}
|
||
switch track {
|
||
case ReleaseTrackBeta:
|
||
return gh.Prerelease && isPrereleaseVersionTag(gh.TagName)
|
||
case ReleaseTrackRelease, "":
|
||
return !gh.Prerelease && isStableVersionTag(gh.TagName)
|
||
case ReleaseTrackAll:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (c *Client) validateConfig() error {
|
||
if c.configErr != nil {
|
||
return c.configErr
|
||
}
|
||
return 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 repositoryFromEnv() (string, string, error) {
|
||
raw := strings.TrimSpace(os.Getenv("DWS_UPGRADE_REPOSITORY"))
|
||
if raw == "" {
|
||
return defaultOwner, defaultRepo, nil
|
||
}
|
||
owner, repo, ok := parseGitHubRepository(raw)
|
||
if !ok {
|
||
return defaultOwner, defaultRepo, fmt.Errorf("DWS_UPGRADE_REPOSITORY 必须是 owner/repo 格式,当前值: %q", raw)
|
||
}
|
||
return owner, repo, nil
|
||
}
|
||
|
||
func parseGitHubRepository(raw string) (string, string, bool) {
|
||
raw = strings.TrimSpace(raw)
|
||
raw = strings.TrimPrefix(raw, "https://github.com/")
|
||
raw = strings.TrimPrefix(raw, "http://github.com/")
|
||
raw = strings.TrimPrefix(raw, "github.com/")
|
||
raw = strings.TrimSuffix(raw, ".git")
|
||
raw = strings.Trim(raw, "/")
|
||
|
||
parts := strings.Split(raw, "/")
|
||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||
return "", "", false
|
||
}
|
||
return parts[0], strings.TrimSuffix(parts[1], ".git"), true
|
||
}
|
||
|
||
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 isVersionLikeTag(tag string) bool {
|
||
tag = stripV(tag)
|
||
core := tag
|
||
if idx := strings.IndexByte(core, '-'); idx >= 0 {
|
||
core = core[:idx]
|
||
}
|
||
parts := strings.Split(core, ".")
|
||
if len(parts) != 3 {
|
||
return false
|
||
}
|
||
for _, p := range parts {
|
||
if p == "" {
|
||
return false
|
||
}
|
||
if _, err := strconv.Atoi(p); err != nil {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func isStableVersionTag(tag string) bool {
|
||
return isVersionLikeTag(tag) && !strings.Contains(stripV(tag), "-")
|
||
}
|
||
|
||
func isPrereleaseVersionTag(tag string) bool {
|
||
return isVersionLikeTag(tag) && strings.Contains(stripV(tag), "-")
|
||
}
|
||
|
||
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)
|
||
}
|