Files

483 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}