feat: add self-upgrade command with GitHub Releases, atomic flow, and cross-platform support

This commit is contained in:
fantiu
2026-04-02 16:34:51 +08:00
parent a0dc5d6183
commit a18ce2e54d
14 changed files with 2114 additions and 263 deletions
+36
View File
@@ -28,6 +28,7 @@
- [Why dws?](#why-dws)
- [Installation](#installation)
- [Upgrade](#upgrade)
- [Getting Started](#getting-started)
- [Quick Start](#quick-start)
- [Using with Agents](#using-with-agents)
@@ -85,6 +86,41 @@ cp dws ~/.local/bin/ # install to PATH
</details>
## Upgrade
dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
```bash
dws upgrade # interactive upgrade to latest version
dws upgrade --check # check for new versions without installing
dws upgrade --list # list all available versions
dws upgrade --version v1.0.7 # upgrade to a specific version
dws upgrade --rollback # rollback to the previous version
dws upgrade -y # skip confirmation prompt
```
<details>
<summary><strong>How it works</strong></summary>
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
| Flag | Description |
|------|-------------|
| `--check` | Check for updates without installing |
| `--list` | List all available versions with changelogs |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
| `--rollback` | Rollback to the previous backed-up version |
| `--force` | Force reinstall even if already on the latest version |
| `--skip-skills` | Skip skill package update |
| `-y` | Skip confirmation prompt |
</details>
## Getting Started
```bash
+36
View File
@@ -28,6 +28,7 @@
- [为什么选择 dws?](#why-dws)
- [安装](#安装)
- [升级](#升级)
- [开始使用](#开始使用)
- [快速开始](#快速开始)
- [在 Agent 中使用](#在-agent-中使用)
@@ -85,6 +86,41 @@ cp dws ~/.local/bin/ # 安装到 PATH
</details>
## 升级
dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
```bash
dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出所有可用版本
dws upgrade --version v1.0.7 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级
```
<details>
<summary><strong>工作原理</strong></summary>
升级过程采用两阶段原子流程,确保一致性:
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
| Flag | 说明 |
|------|------|
| `--check` | 仅检查更新,不安装 |
| `--list` | 列出所有可用版本及更新日志 |
| `--version` | 升级到指定版本(如 `v1.0.7`) |
| `--rollback` | 回滚到上一个备份版本 |
| `--force` | 强制重新安装,即使已是最新版本 |
| `--skip-skills` | 跳过技能包更新 |
| `-y` | 跳过确认提示 |
</details>
## 开始使用
```bash
+391 -214
View File
@@ -5,6 +5,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -13,9 +14,20 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
var (
ugBold = color.New(color.Bold).SprintFunc()
ugGreen = color.New(color.FgGreen).SprintFunc()
ugYellow = color.New(color.FgYellow).SprintFunc()
ugRed = color.New(color.FgRed).SprintFunc()
ugCyan = color.New(color.FgCyan).SprintFunc()
ugDim = color.New(color.Faint).SprintFunc()
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
)
func newUpgradeCommand() *cobra.Command {
var (
flagCheck bool
@@ -24,7 +36,6 @@ func newUpgradeCommand() *cobra.Command {
flagRollback bool
flagForce bool
flagSkipSkills bool
flagSkipVerify bool
)
cmd := &cobra.Command{
@@ -32,37 +43,32 @@ func newUpgradeCommand() *cobra.Command {
Short: "升级 DWS CLI 到最新版本",
Long: `检查并升级 DWS CLI 到最新版本。
升级内容:
• CLI 二进制文件(自动匹配当前平台)
• 技能包 dws-skills(SKILL.md + references + scripts)
安全验证:
• SHA256 校验和验证
示例:
dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出所有可用版本
dws upgrade --version 1.0.5 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级`,
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
Example: ` dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出所有可用版本
dws upgrade --version v1.0.5 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
format := resolveUpgradeFormat(cmd)
if flagList {
return runUpgradeList()
return runUpgradeList(cmd, format)
}
if flagRollback {
return runUpgradeRollback(yes)
}
if flagCheck {
return runUpgradeCheck()
return runUpgradeCheck(cmd, format)
}
return runUpgrade(cmd.Context(), upgradeOptions{
targetVersion: flagVersion,
force: flagForce,
skipSkills: flagSkipSkills,
skipVerify: flagSkipVerify,
yes: yes,
})
},
@@ -74,7 +80,6 @@ func newUpgradeCommand() *cobra.Command {
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
cmd.Flags().BoolVar(&flagSkipVerify, "skip-verify", false, "跳过校验(危险)")
return cmd
}
@@ -83,82 +88,122 @@ type upgradeOptions struct {
targetVersion string
force bool
skipSkills bool
skipVerify bool
yes bool
}
// --- dws upgrade --check ---
func runUpgradeCheck() error {
fmt.Println("检查更新...")
func runUpgradeCheck(cmd *cobra.Command, format string) error {
client := upgrade.NewClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim("检查更新..."))
}
latest, err := client.FetchLatestRelease()
if err != nil {
return fmt.Errorf("检查更新失败: %w", err)
}
currentVer := version
if !upgrade.NeedsUpgrade(currentVer, latest.Version) {
fmt.Printf("已是最新版本 %s\n", currentVer)
needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version)
if format == "json" {
return writeJSON(cmd.OutOrStdout(), map[string]any{
"current_version": ensureV(currentVer),
"latest_version": "v" + latest.Version,
"needs_upgrade": needsUpgrade,
"release_date": latest.Date,
"prerelease": latest.Prerelease,
"changelog": parseChangelogEntries(latest.Changelog, 10),
"release_url": latest.HTMLURL,
})
}
if !needsUpgrade {
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
return nil
}
fmt.Printf("发现新版本: %s → v%s\n", currentVer, latest.Version)
fmt.Println()
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+latest.Version))
if latest.Date != "" {
fmt.Printf(" 发布日期: %s\n", latest.Date)
fmt.Printf(" %s %s\n", ugBold("发布日期: "), latest.Date)
}
if latest.Prerelease {
fmt.Printf(" 通道: pre-release\n")
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
}
if latest.Changelog != "" {
fmt.Printf(" 更新内容: %s\n", truncateChangelog(latest.Changelog))
if entries := parseChangelogEntries(latest.Changelog, 5); len(entries) > 0 {
fmt.Printf(" %s\n", ugBold("更新内容:"))
for _, e := range entries {
fmt.Printf(" %s %s\n", ugGreen("•"), e)
}
}
fmt.Println()
fmt.Println("运行 dws upgrade 进行升级")
fmt.Printf(" %s\n", ugDim("运行 dws upgrade 进行升级"))
return nil
}
// --- dws upgrade --list ---
func runUpgradeList() error {
fmt.Println("获取版本列表...")
func runUpgradeList(cmd *cobra.Command, format string) error {
client := upgrade.NewClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim("获取版本列表..."))
}
versions, err := client.FetchAllReleases()
if err != nil {
return fmt.Errorf("获取版本列表失败: %w", err)
}
currentVer := strings.TrimPrefix(version, "v")
if format == "json" {
var items []map[string]any
for _, v := range versions {
items = append(items, map[string]any{
"version": "v" + v.Version,
"date": v.Date,
"prerelease": v.Prerelease,
"installed": v.Version == currentVer,
"changelog": parseChangelogEntries(v.Changelog, 10),
})
}
return writeJSON(cmd.OutOrStdout(), map[string]any{
"current_version": ensureV(version),
"versions": items,
})
}
if len(versions) == 0 {
fmt.Println("未找到任何版本")
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
return nil
}
fmt.Println()
fmt.Printf(" %-12s %-12s %-12s %s\n", "VERSION", "DATE", "TYPE", "CHANGELOG")
fmt.Printf(" %s\n", strings.Repeat("-", 70))
fmt.Printf(" %s\n", ugBold(fmt.Sprintf("%-12s %-12s %-12s %s", "VERSION", "DATE", "TYPE", "CHANGELOG")))
fmt.Printf(" %s\n", ugDim(strings.Repeat("─", 70)))
currentVer := strings.TrimPrefix(version, "v")
for _, v := range versions {
releaseType := "stable"
releaseType := ugGreen("stable")
if v.Prerelease {
releaseType = "pre-release"
releaseType = ugYellow("pre-release")
}
versionStr := fmt.Sprintf("v%-11s", v.Version)
marker := ""
if v.Version == currentVer {
marker = " (已安装)"
versionStr = ugBoldGrn(versionStr)
marker = ugCyan(" ← 已安装")
}
changelog := v.Changelog
if changelog == "" {
changelog = "-"
}
fmt.Printf(" %-12s %-12s %-12s %s%s\n", v.Version, v.Date, releaseType, changelog, marker)
changelog := ugDim(truncateChangelogForList(v.Changelog, 40))
fmt.Printf(" %s %-12s %-23s %s%s\n", versionStr, v.Date, releaseType, changelog, marker)
}
fmt.Println()
fmt.Printf("已安装: %s\n", version)
fmt.Println("提示: 使用 dws upgrade --version <version> 安装指定版本")
fmt.Printf(" %s %s\n", ugBold("当前版本:"), ugBoldGrn(ensureV(version)))
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.5 安装指定版本"))
return nil
}
@@ -175,18 +220,17 @@ func runUpgradeRollback(yes bool) error {
return fmt.Errorf("没有可用的备份,无法回滚")
}
fmt.Println("可用备份:")
for i, b := range backups {
if i >= 5 {
fmt.Printf(" ... 还有 %d 个备份\n", len(backups)-5)
break
}
fmt.Printf(" v%s (%s)\n", b.Version, b.CreatedAt.Format("2006-01-02 15:04"))
}
target := backups[0]
targetVer := ensureV(target.Version)
currentVer := ensureV(version)
fmt.Println()
fmt.Printf(" 当前版本: %s\n", ugBold(currentVer))
fmt.Printf(" 回滚目标: %s %s\n", ugCyan(targetVer), ugDim("("+target.CreatedAt.Format("2006-01-02 15:04")+")"))
if !yes {
fmt.Println()
fmt.Print("是否回滚到最近的备份? [y/N] ")
fmt.Printf("是否回滚到 %s? [y/N] ", ugBold(targetVer))
var answer string
fmt.Scanln(&answer)
if answer != "y" && answer != "Y" {
@@ -195,41 +239,43 @@ func runUpgradeRollback(yes bool) error {
}
}
fmt.Print("回滚中...")
if err := rm.Rollback(); err != nil {
fmt.Print(" 回滚中...")
if err := rm.RollbackTo(target); err != nil {
return fmt.Errorf("\n回滚失败: %w", err)
}
fmt.Println(" 完成")
// Verify the rolled-back binary
currentExe, _ := upgrade.CurrentBinaryPath()
if currentExe != "" {
if info, err := os.Stat(currentExe); err == nil && info.Size() > 0 && info.Mode()&0111 != 0 {
fmt.Println("验证: 文件存在且可执行")
}
}
fmt.Printf(" %s\n", ugGreen("✓"))
fmt.Println()
fmt.Printf("已回滚到 v%s\n", backups[0].Version)
fmt.Println("运行 dws version 验证")
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Printf(" %s 已回滚 %s %s %s\n", ugBoldGrn("✔"), ugDim(currentVer), ugBold("→"), ugBoldGrn(targetVer))
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Println()
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
return nil
}
// --- dws upgrade (full) ---
//
// The upgrade flow is split into two phases for atomicity:
// Phase 1 (Prepare): download, verify, extract — all in a temp directory, zero side effects.
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
// If anything fails in Phase 1, no files on disk are modified.
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Println("检查更新...")
fmt.Printf(" %s\n", ugDim("检查更新..."))
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
return fmt.Errorf("初始化目录结构失败: %w", err)
}
upgrade.CleanupStaleFiles()
client := upgrade.NewClient()
var release *upgrade.ReleaseInfo
var err error
if opts.targetVersion != "" {
fmt.Printf("指定版本: v%s\n", opts.targetVersion)
fmt.Printf(" 指定版本: %s\n", ugCyan("v"+opts.targetVersion))
release, err = client.FetchReleaseByTag(opts.targetVersion)
if err != nil {
return fmt.Errorf("获取版本 %s 信息失败: %w", opts.targetVersion, err)
@@ -243,23 +289,22 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
currentVer := version
if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) {
fmt.Printf("已是最新版本 %s\n", currentVer)
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
return nil
}
// Show update info
fmt.Printf("\n发现新版本: %s → v%s\n", currentVer, release.Version)
fmt.Println()
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
if release.Date != "" {
fmt.Printf(" 发布日期: %s\n", release.Date)
fmt.Printf(" %s %s\n", ugBold("发布日期: "), release.Date)
}
if release.Prerelease {
fmt.Println(" 注意: 这是预发布版本")
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
}
// Confirm
if !opts.yes {
fmt.Println()
fmt.Print("是否升级? [y/N] ")
fmt.Printf("是否升级? [y/N] ")
var answer string
fmt.Scanln(&answer)
if answer != "y" && answer != "Y" {
@@ -268,14 +313,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
}
// Find binary asset for current platform
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
if err != nil {
return err
}
fmt.Printf("平台: %s\n", binaryAsset.Name)
// Create temp workspace
tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
if err != nil {
tmpDir, err = os.MkdirTemp("", "dws-upgrade-*")
@@ -285,180 +327,208 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
defer os.RemoveAll(tmpDir)
// Backup current version
fmt.Print("备份当前版本...")
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
const totalSteps = 5
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
// ========================================================================
// Phase 1: Prepare (download + verify + extract — no side effects)
// ========================================================================
fmt.Println()
// --- Step 1: Backup ---
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
rm := upgrade.NewRollbackManager()
backupPath, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
if backupErr != nil {
fmt.Printf(" 警告: %v\n", backupErr)
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
} else {
fmt.Println(" 完成")
fmt.Printf(" %s\n", ugGreen("✓"))
}
// Download checksums.txt for verification
// Fetch checksums.txt (needed for strict verification of both binary and skills)
var checksumsContent string
if !opts.skipVerify {
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
if checksumsAsset != nil {
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
checksumsContent = string(data)
}
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
if checksumsAsset != nil {
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
checksumsContent = string(data)
}
}
}
// Download binary
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
fmt.Print("下载二进制...")
// --- Step 2: Download (binary + skills together) ---
sl := stepFmt(2)
progressPrefix := fmt.Sprintf(" %s 下载 %s", sl, ugCyan(binaryAsset.Name))
fmt.Print(progressPrefix)
start := time.Now()
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
func(percent float64, downloaded, total int64) {
bar := progressBar(percent)
fmt.Printf("\r下载二进制... [%s] %5.1f%%", bar, percent)
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
})
if err != nil {
return fmt.Errorf("\n下载失败: %w", err)
fmt.Println()
return fmt.Errorf("下载二进制失败: %w", err)
}
elapsed := time.Since(start)
fmt.Printf("\r下载二进制... 完成 (%.1fMB, %.1fs)\n", float64(n)/1024/1024, elapsed.Seconds())
clearLine := strings.Repeat(" ", 100)
// Verify SHA256
if !opts.skipVerify {
fmt.Print("验证 SHA256...")
verified := false
// Priority 1: checksums.txt
if checksumsContent != "" {
if err := upgrade.VerifyFileFromChecksums(binaryArchivePath, binaryAsset.Name, checksumsContent); err == nil {
verified = true
}
var skillsZipPath string
if hasSkills {
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包下载失败: %w", dlErr)
}
// Priority 2: GitHub asset digest
if !verified {
if digest := upgrade.ExtractDigestSHA256(binaryAsset.Digest); digest != "" {
if err := upgrade.VerifySHA256(binaryArchivePath, digest); err == nil {
verified = true
}
}
}
if verified {
fmt.Println(" 通过")
} else {
fmt.Println(" 跳过 (无可用校验信息)")
fmt.Printf(" %s\n", ugGreen("✓"))
} else {
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
}
// --- Step 3: Verify SHA256 (binary + skills together) ---
if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
return err
}
if hasSkills {
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
return err
}
}
// Extract archive
fmt.Print("解压...")
// --- Step 4: Extract + validate ---
fmt.Printf(" %s 解压并验证...", stepFmt(4))
extractDir := filepath.Join(tmpDir, "extracted")
if strings.HasSuffix(binaryAsset.Name, ".zip") {
if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil {
return fmt.Errorf("\n解压失败: %w", err)
fmt.Println()
return fmt.Errorf("解压失败: %w", err)
}
} else {
// tar.gz
if err := extractTarGz(binaryArchivePath, extractDir); err != nil {
return fmt.Errorf("\n解压失败: %w", err)
fmt.Println()
return fmt.Errorf("解压失败: %w", err)
}
}
binaryPath := upgrade.FindBinaryInDir(extractDir)
if binaryPath == "" {
fmt.Println()
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
}
fmt.Println(" 完成")
// Validate new binary
fmt.Print("验证新版本...")
if err := validateNewBinary(binaryPath, release.Version); err != nil {
return fmt.Errorf("\n验证失败: %w", err)
fmt.Println()
return fmt.Errorf("验证失败: %w", err)
}
fmt.Println(" 通过")
// Replace binary
fmt.Print("替换二进制...")
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
fmt.Printf("\n替换失败: %v\n", err)
if backupPath != "" {
fmt.Print("尝试回滚...")
if rbErr := rm.Rollback(); rbErr != nil {
fmt.Printf(" 回滚也失败了: %v\n", rbErr)
} else {
fmt.Println(" 已回滚")
}
var skillSrc string
if hasSkills {
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
os.MkdirAll(skillsExtractDir, 0755)
if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil {
fmt.Println()
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
}
skillSrc = upgrade.LocateSkillMD(skillsExtractDir)
if skillSrc == "" {
fmt.Println()
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
}
return err
}
fmt.Println(" 完成")
fmt.Printf(" %s\n", ugGreen("✓"))
// Upgrade skills
if !opts.skipSkills {
upgradeSkills(ctx, release, tmpDir, opts.skipVerify, checksumsContent)
// ========================================================================
// Phase 2: Apply (all preparations succeeded — now do the actual changes)
// ========================================================================
// --- Step 5: Replace binary + install skills ---
fmt.Printf(" %s 替换并安装...", stepFmt(5))
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("替换二进制失败: %w", err)
}
if hasSkills {
result, installErr := upgrade.UpgradeSkillLocations(skillSrc)
if installErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包安装失败: %w", installErr)
}
failed := result.Failed()
if len(failed) > 0 {
fmt.Printf(" %s\n", ugRed("✗"))
for _, d := range failed {
fmt.Printf(" %s %s %s\n", ugRed("✗"), shortenHome(d.Dir), ugDim(d.Err.Error()))
}
return fmt.Errorf("技能包安装到 %d 个目录失败,请检查权限后手动重试: dws upgrade --force", len(failed))
}
succeeded := result.Succeeded()
fmt.Printf(" %s\n", ugGreen("✓"))
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("二进制已替换"))
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim(fmt.Sprintf("技能包已安装 (%d 个位置)", len(succeeded))))
for _, d := range succeeded {
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
}
} else {
fmt.Printf(" %s\n", ugGreen("✓"))
}
// Cleanup old backups
rm.Cleanup(5)
// Summary
fmt.Println()
fmt.Printf("升级完成! %s → v%s\n", currentVer, release.Version)
fmt.Println("运行 dws version 验证")
if backupPath != "" {
fmt.Println("如遇问题,运行 dws upgrade --rollback 回滚")
}
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Printf(" %s 升级完成 %s %s %s\n", ugBoldGrn("✔"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Println()
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
fmt.Printf(" %s\n", ugDim("如遇问题,运行 dws upgrade --rollback 回滚"))
return nil
}
// upgradeSkills downloads and installs the skills pack.
func upgradeSkills(ctx context.Context, release *upgrade.ReleaseInfo, tmpDir string, skipVerify bool, checksumsContent string) {
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
if skillsAsset == nil {
return
}
// strictVerifyFile performs SHA256 verification with strict semantics:
// - If checksum info is available and matches → ✓
// - If checksum info is available but MISMATCHES → error (abort upgrade)
// - If no checksum info at all → skip (no data to compare against)
func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent string) error {
fmt.Printf(" %s 校验 %s...", label, fileName)
fmt.Print("下载技能包...")
skillsZipPath := filepath.Join(tmpDir, "dws-skills.zip")
n, err := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath)
if err != nil {
fmt.Printf(" 跳过 (%v)\n", err)
return
}
fmt.Printf(" %.1fKB 完成\n", float64(n)/1024)
// Verify skills SHA256
if !skipVerify && checksumsContent != "" {
if err := upgrade.VerifyFileFromChecksums(skillsZipPath, "dws-skills.zip", checksumsContent); err != nil {
fmt.Printf(" 技能包校验失败: %v (继续安装)\n", err)
// Source 1: checksums.txt
if checksumsContent != "" {
checksums := upgrade.ParseChecksumFile(checksumsContent)
if expectedHash, ok := checksums[fileName]; ok {
if err := upgrade.VerifySHA256(filePath, expectedHash); err != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
}
fmt.Printf(" %s\n", ugGreen("✓"))
return nil
}
}
// Extract to temp
extractDir := filepath.Join(tmpDir, "skills-extracted")
os.MkdirAll(extractDir, 0755)
if err := upgrade.ExtractZip(skillsZipPath, extractDir); err != nil {
fmt.Printf(" 技能包解压失败: %v (已跳过)\n", err)
return
// Source 2: GitHub asset digest
if digest := upgrade.ExtractDigestSHA256(assetDigest); digest != "" {
if err := upgrade.VerifySHA256(filePath, digest); err != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
}
fmt.Printf(" %s\n", ugGreen("✓"))
return nil
}
// Locate SKILL.md root
skillSrc := upgrade.LocateSkillMD(extractDir)
if skillSrc == "" {
fmt.Println(" 技能包中未找到 SKILL.md (已跳过)")
return
}
// Install to all known locations
fmt.Print("安装技能包...")
updated, err := upgrade.UpgradeSkillLocations(skillSrc)
if err != nil {
fmt.Printf(" 失败: %v\n", err)
return
}
fmt.Printf(" 完成 (%d 个位置)\n", len(updated))
for _, dir := range updated {
fmt.Printf(" → %s\n", shortenHome(dir))
}
// No checksum info available at all
fmt.Printf(" %s\n", ugDim("- 跳过 (无可用校验信息)"))
return nil
}
// validateNewBinary checks the downloaded binary is valid.
@@ -509,26 +579,133 @@ func progressBar(percent float64) string {
return strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
}
func truncateChangelog(s string) string {
lines := strings.Split(s, "\n")
var result []string
for i, line := range lines {
if i >= 3 {
result = append(result, "...")
// parseChangelogEntries extracts human-readable commit messages from a
// GitHub Release body. The body typically looks like:
//
// ## Changelog
// * abcdef1234 - some commit message
// * 0123456789 Merge branch 'main' into main
//
// We strip the hash prefix and skip noisy entries (Merge branch, Merge pull request).
func parseChangelogEntries(body string, maxEntries int) []string {
var entries []string
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "* ")
line = strings.TrimPrefix(line, "- ")
msg := stripCommitHash(line)
if msg == "" {
continue
}
if isNoiseCommit(msg) {
continue
}
entries = append(entries, msg)
if maxEntries > 0 && len(entries) >= maxEntries {
break
}
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
result = append(result, line)
}
return entries
}
// truncateChangelog returns a short one-line summary for the --check output.
func truncateChangelog(body string) string {
entries := parseChangelogEntries(body, 3)
if len(entries) == 0 {
return ""
}
return strings.Join(entries, "; ")
}
// truncateChangelogForList returns a compact summary for the --list table.
func truncateChangelogForList(body string, maxLen int) string {
entries := parseChangelogEntries(body, 2)
if len(entries) == 0 {
return "-"
}
summary := strings.Join(entries, "; ")
if len(summary) > maxLen {
return summary[:maxLen-3] + "..."
}
return summary
}
// stripCommitHash removes a leading Git commit hash (7-40 hex chars)
// and optional separator (" - ", " ") from a line.
func stripCommitHash(line string) string {
if len(line) < 8 {
return line
}
// Check if line starts with hex chars (commit hash)
hashEnd := 0
for hashEnd < len(line) && hashEnd < 40 {
c := line[hashEnd]
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
hashEnd++
} else {
break
}
}
if len(result) == 0 {
if len(s) > 80 {
return s[:77] + "..."
}
return s
if hashEnd < 7 {
return line
}
return strings.Join(result, "; ")
rest := line[hashEnd:]
rest = strings.TrimPrefix(rest, " - ")
rest = strings.TrimLeft(rest, " ")
return rest
}
func isNoiseCommit(msg string) bool {
lower := strings.ToLower(msg)
noisePatterns := []string{
"merge branch",
"merge pull request",
"merge remote-tracking",
}
for _, p := range noisePatterns {
if strings.HasPrefix(lower, p) {
return true
}
}
return false
}
// ensureV ensures a version string has a "v" prefix for display consistency.
// Non-semver values like "dev" or "unknown" are returned as-is.
func ensureV(ver string) string {
if ver == "" {
return "v0.0.0"
}
if strings.HasPrefix(ver, "v") {
return ver
}
// Only add "v" prefix for semver-like strings (starts with digit)
if len(ver) > 0 && ver[0] >= '0' && ver[0] <= '9' {
return "v" + ver
}
return ver
}
// resolveUpgradeFormat returns "json" only when the user explicitly passes -f json.
// Unlike other commands, upgrade defaults to table (human-friendly) output.
func resolveUpgradeFormat(cmd *cobra.Command) string {
pf := cmd.Root().PersistentFlags()
if pf.Changed("format") {
if f, err := pf.GetString("format"); err == nil {
return strings.ToLower(strings.TrimSpace(f))
}
}
return "table"
}
func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}
func shortenHome(path string) string {
+430
View File
@@ -0,0 +1,430 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// --- ensureV ---
func TestEnsureV(t *testing.T) {
tests := []struct {
in string
want string
}{
{"1.0.6", "v1.0.6"},
{"v1.0.6", "v1.0.6"},
{"0.0.1", "v0.0.1"},
{"dev", "dev"},
{"unknown", "unknown"},
{"", "v0.0.0"},
{"v", "v"},
}
for _, tt := range tests {
got := ensureV(tt.in)
if got != tt.want {
t.Errorf("ensureV(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// --- parseChangelogEntries ---
func TestParseChangelogEntries(t *testing.T) {
body := `## Changelog
* abcdef1234567 - fix login bug
* 0123456789abc Merge branch 'main' into main
* fedcba9876543 - add upgrade command
* deadbeef12345 Merge pull request #42
* 1234567890abc - improve error handling
`
entries := parseChangelogEntries(body, 10)
if len(entries) != 3 {
t.Fatalf("len(entries) = %d, want 3 (merge commits should be filtered)", len(entries))
}
if entries[0] != "fix login bug" {
t.Errorf("entries[0] = %q, want %q", entries[0], "fix login bug")
}
if entries[1] != "add upgrade command" {
t.Errorf("entries[1] = %q, want %q", entries[1], "add upgrade command")
}
if entries[2] != "improve error handling" {
t.Errorf("entries[2] = %q, want %q", entries[2], "improve error handling")
}
}
func TestParseChangelogEntries_MaxLimit(t *testing.T) {
body := "* abc1234 - msg1\n* def5678 - msg2\n* ghi9012 - msg3\n"
entries := parseChangelogEntries(body, 2)
if len(entries) != 2 {
t.Errorf("len = %d, want 2 (should respect maxEntries)", len(entries))
}
}
func TestParseChangelogEntries_EmptyBody(t *testing.T) {
entries := parseChangelogEntries("", 10)
if len(entries) != 0 {
t.Errorf("len = %d, want 0 for empty body", len(entries))
}
}
func TestParseChangelogEntries_OnlyHeaders(t *testing.T) {
body := "## Changelog\n## Another heading\n"
entries := parseChangelogEntries(body, 10)
if len(entries) != 0 {
t.Errorf("len = %d, want 0 for headers-only body", len(entries))
}
}
func TestParseChangelogEntries_OnlyMergeCommits(t *testing.T) {
body := "* abc1234 Merge branch 'main'\n* def5678 Merge pull request #10\n"
entries := parseChangelogEntries(body, 10)
if len(entries) != 0 {
t.Errorf("len = %d, want 0 (all merge commits should be filtered)", len(entries))
}
}
func TestParseChangelogEntries_DashPrefixedLines(t *testing.T) {
body := "- fix bug\n- add feature\n"
entries := parseChangelogEntries(body, 10)
if len(entries) != 2 {
t.Fatalf("len = %d, want 2", len(entries))
}
if entries[0] != "fix bug" {
t.Errorf("entries[0] = %q, want %q", entries[0], "fix bug")
}
}
// --- stripCommitHash ---
func TestStripCommitHash(t *testing.T) {
tests := []struct {
in string
want string
}{
{"abcdef1234567 - fix bug", "fix bug"},
{"abcdef1234567 fix bug", "fix bug"},
{"short", "short"}, // too short to be a hash
{"abc123", "abc123"}, // less than 7 hex chars
{"no hash here", "no hash here"},
{"ABCDEF1234567 - upper case hash", "upper case hash"},
}
for _, tt := range tests {
got := stripCommitHash(tt.in)
if got != tt.want {
t.Errorf("stripCommitHash(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// --- isNoiseCommit ---
func TestIsNoiseCommit(t *testing.T) {
tests := []struct {
msg string
want bool
}{
{"Merge branch 'main'", true},
{"merge branch 'develop'", true},
{"Merge pull request #42", true},
{"Merge remote-tracking branch 'origin/main'", true},
{"fix login bug", false},
{"add new feature", false},
{"merge conflicts resolved", false}, // doesn't start with "merge branch"
}
for _, tt := range tests {
got := isNoiseCommit(tt.msg)
if got != tt.want {
t.Errorf("isNoiseCommit(%q) = %v, want %v", tt.msg, got, tt.want)
}
}
}
// --- truncateChangelog ---
func TestTruncateChangelog(t *testing.T) {
body := "## Changelog\n* abc1234 - fix A\n* def5678 - fix B\n* ghi9012 - fix C\n* jkl3456 - fix D\n"
result := truncateChangelog(body)
if result == "" {
t.Error("truncateChangelog returned empty")
}
// Should contain max 3 entries separated by "; "
parts := strings.Split(result, "; ")
if len(parts) > 3 {
t.Errorf("truncateChangelog should have at most 3 entries, got %d", len(parts))
}
}
func TestTruncateChangelog_EmptyBody(t *testing.T) {
if got := truncateChangelog(""); got != "" {
t.Errorf("truncateChangelog('') = %q, want empty", got)
}
}
// --- truncateChangelogForList ---
func TestTruncateChangelogForList(t *testing.T) {
tests := []struct {
body string
maxLen int
want string
}{
{"", 40, "-"},
{"## Changelog\n", 40, "-"},
}
for _, tt := range tests {
got := truncateChangelogForList(tt.body, tt.maxLen)
if got != tt.want {
t.Errorf("truncateChangelogForList(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
}
}
}
func TestTruncateChangelogForList_Truncation(t *testing.T) {
body := "* abc1234 - a very long commit message that should be truncated eventually\n"
result := truncateChangelogForList(body, 20)
if len(result) > 20 {
t.Errorf("result len = %d, want <= 20", len(result))
}
if !strings.HasSuffix(result, "...") {
t.Errorf("truncated result should end with '...' , got %q", result)
}
}
// --- progressBar ---
func TestProgressBar(t *testing.T) {
tests := []struct {
percent float64
filled int
}{
{0, 0},
{50, 10},
{100, 20},
{150, 20}, // capped
}
for _, tt := range tests {
bar := progressBar(tt.percent)
if len(bar) != 20*len("█") && len(bar) != 20*len("░") {
// Since multi-byte chars, just check total rune count
runes := []rune(bar)
if len(runes) != 20 {
t.Errorf("progressBar(%v) rune count = %d, want 20", tt.percent, len(runes))
}
}
filledCount := strings.Count(bar, "█")
if filledCount != tt.filled {
t.Errorf("progressBar(%v) filled = %d, want %d", tt.percent, filledCount, tt.filled)
}
}
}
// --- shortenHome ---
func TestShortenHome(t *testing.T) {
// Non-home path should be unchanged
got := shortenHome("/tmp/somewhere")
if got != "/tmp/somewhere" {
t.Errorf("shortenHome(/tmp/somewhere) = %q", got)
}
}
// --- resolveUpgradeFormat ---
func TestResolveUpgradeFormat_Default(t *testing.T) {
root := &cobra.Command{}
root.PersistentFlags().String("format", "json", "output format")
child := &cobra.Command{}
root.AddCommand(child)
// format not changed => should default to "table" for upgrade
got := resolveUpgradeFormat(child)
if got != "table" {
t.Errorf("resolveUpgradeFormat(unchanged) = %q, want %q", got, "table")
}
}
func TestResolveUpgradeFormat_ExplicitJSON(t *testing.T) {
root := &cobra.Command{}
root.PersistentFlags().String("format", "json", "output format")
child := &cobra.Command{}
root.AddCommand(child)
// Simulate user explicitly setting format
root.PersistentFlags().Set("format", "json")
got := resolveUpgradeFormat(child)
if got != "json" {
t.Errorf("resolveUpgradeFormat(explicit json) = %q, want %q", got, "json")
}
}
func TestResolveUpgradeFormat_ExplicitTable(t *testing.T) {
root := &cobra.Command{}
root.PersistentFlags().String("format", "json", "output format")
child := &cobra.Command{}
root.AddCommand(child)
root.PersistentFlags().Set("format", "table")
got := resolveUpgradeFormat(child)
if got != "table" {
t.Errorf("resolveUpgradeFormat(explicit table) = %q, want %q", got, "table")
}
}
// --- writeJSON ---
func TestWriteJSON(t *testing.T) {
var buf bytes.Buffer
data := map[string]any{
"version": "v1.0.6",
"ok": true,
}
if err := writeJSON(&buf, data); err != nil {
t.Fatalf("writeJSON() error = %v", err)
}
output := buf.String()
if !strings.Contains(output, `"version": "v1.0.6"`) {
t.Errorf("output missing version: %s", output)
}
if !strings.Contains(output, `"ok": true`) {
t.Errorf("output missing ok: %s", output)
}
}
// --- strictVerifyFile ---
func TestStrictVerifyFile_MatchesChecksums(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "test.tar.gz")
content := []byte("valid binary content")
os.WriteFile(filePath, content, 0644)
hash := computeTestSHA256(t, content)
checksums := hash + " test.tar.gz\n"
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
if err != nil {
t.Errorf("expected success, got %v", err)
}
}
func TestStrictVerifyFile_ChecksumMismatch(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "test.tar.gz")
os.WriteFile(filePath, []byte("tampered content"), 0644)
checksums := "0000000000000000000000000000000000000000000000000000000000000000 test.tar.gz\n"
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
if err == nil {
t.Fatal("expected error for checksum mismatch")
}
if !strings.Contains(err.Error(), "校验失败") {
t.Errorf("error = %q, want to contain '校验失败'", err.Error())
}
}
func TestStrictVerifyFile_DigestMismatch(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "test.tar.gz")
os.WriteFile(filePath, []byte("tampered"), 0644)
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz",
"sha256:0000000000000000000000000000000000000000000000000000000000000000",
"")
if err == nil {
t.Fatal("expected error for digest mismatch")
}
}
func TestStrictVerifyFile_NoChecksumInfo(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "test.tar.gz")
os.WriteFile(filePath, []byte("content"), 0644)
err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", "")
if err != nil {
t.Errorf("no checksum info should skip, not error: %v", err)
}
}
func TestStrictVerifyFile_FileNotInChecksums_FallsToDigest(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "skills.zip")
content := []byte("skills content")
os.WriteFile(filePath, content, 0644)
hash := computeTestSHA256(t, content)
// checksums.txt has entries but NOT skills.zip
checksums := "abcdef1234567890 other-file.tar.gz\n"
err := strictVerifyFile("[1/5]", filePath, "skills.zip", "sha256:"+hash, checksums)
if err != nil {
t.Errorf("should fall through to digest and succeed: %v", err)
}
}
func computeTestSHA256(t *testing.T, data []byte) string {
t.Helper()
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// --- newUpgradeCommand ---
func TestNewUpgradeCommand_Flags(t *testing.T) {
cmd := newUpgradeCommand()
if cmd.Use != "upgrade" {
t.Errorf("Use = %q, want upgrade", cmd.Use)
}
expectedFlags := []string{"check", "list", "version", "rollback", "force", "skip-skills"}
for _, name := range expectedFlags {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("missing flag: --%s", name)
}
}
}
func TestNewUpgradeCommand_NoArgs(t *testing.T) {
cmd := newUpgradeCommand()
// Simulate passing positional args - should error with cobra.NoArgs
cmd.SetArgs([]string{"rollback"})
err := cmd.Execute()
if err == nil {
t.Error("expected error for positional args (NoArgs)")
}
}
func TestNewUpgradeCommand_Help(t *testing.T) {
cmd := newUpgradeCommand()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetArgs([]string{"--help"})
cmd.Execute()
help := buf.String()
if !strings.Contains(help, "upgrade") {
t.Error("help should contain 'upgrade'")
}
if !strings.Contains(help, "--check") {
t.Error("help should contain --check")
}
if !strings.Contains(help, "--rollback") {
t.Error("help should contain --rollback")
}
}
+1 -1
View File
@@ -176,7 +176,7 @@ func isRetriable(err error) bool {
return true
}
errStr := err.Error()
for _, pattern := range []string{"connection reset", "connection refused", "timeout", "temporary failure", "EOF"} {
for _, pattern := range []string{"connection reset", "connection refused", "timeout", "temporary failure", "eof"} {
if strings.Contains(strings.ToLower(errStr), pattern) {
return true
}
+174
View File
@@ -0,0 +1,174 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestDownload_Success(t *testing.T) {
body := "hello world binary content"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.Write([]byte(body))
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "downloaded")
n, err := Download(server.URL+"/file.tar.gz", dest)
if err != nil {
t.Fatalf("Download() error = %v", err)
}
if n != int64(len(body)) {
t.Errorf("Download() = %d bytes, want %d", n, len(body))
}
got, _ := os.ReadFile(dest)
if string(got) != body {
t.Errorf("file content = %q, want %q", string(got), body)
}
}
func TestDownload_HTTP404(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "notfound")
_, err := Download(server.URL+"/missing", dest)
if err == nil {
t.Fatal("Download() expected error for 404")
}
}
func TestDownload_HTTP500_RetriesAndFails(t *testing.T) {
var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "err")
cfg := &DownloadConfig{
MaxRetries: 2,
Timeout: 5 * time.Second,
}
_, err := DownloadWithConfig(context.Background(), server.URL+"/fail", dest, cfg)
if err == nil {
t.Fatal("expected error after retries")
}
// 500 is not retriable (no "connection reset" pattern), so only 1 attempt
got := atomic.LoadInt32(&attempts)
if got != 1 {
t.Errorf("attempts = %d, want 1 (500 is not retriable)", got)
}
}
func TestDownload_ContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("late"))
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
dest := filepath.Join(t.TempDir(), "cancelled")
_, err := DownloadWithProgress(ctx, server.URL+"/slow", dest, nil)
if err == nil {
t.Fatal("expected error for cancelled context")
}
}
func TestDownloadWithProgress_Callback(t *testing.T) {
body := make([]byte, 1024)
for i := range body {
body[i] = 'A'
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.Write(body)
}))
defer server.Close()
var called int32
dest := filepath.Join(t.TempDir(), "progress")
n, err := DownloadWithProgress(context.Background(), server.URL+"/file", dest,
func(percent float64, downloaded, total int64) {
atomic.AddInt32(&called, 1)
})
if err != nil {
t.Fatalf("DownloadWithProgress() error = %v", err)
}
if n != int64(len(body)) {
t.Errorf("bytes = %d, want %d", n, len(body))
}
if atomic.LoadInt32(&called) == 0 {
t.Error("progress callback was never called")
}
}
func TestDownload_CreatesParentDirectories(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("data"))
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "a", "b", "c", "file")
_, err := Download(server.URL+"/file", dest)
if err != nil {
t.Fatalf("Download() error = %v", err)
}
if _, err := os.Stat(dest); err != nil {
t.Errorf("file not created: %v", err)
}
}
func TestDefaultDownloadConfig(t *testing.T) {
cfg := DefaultDownloadConfig()
if cfg.MaxRetries != 3 {
t.Errorf("MaxRetries = %d, want 3", cfg.MaxRetries)
}
if cfg.Timeout != 10*time.Minute {
t.Errorf("Timeout = %v, want 10m", cfg.Timeout)
}
if cfg.ProgressInterval != 200*time.Millisecond {
t.Errorf("ProgressInterval = %v, want 200ms", cfg.ProgressInterval)
}
}
func TestIsRetriable(t *testing.T) {
tests := []struct {
err error
want bool
}{
{nil, false},
{fmt.Errorf("connection reset by peer"), true},
{fmt.Errorf("Connection Refused"), true},
{fmt.Errorf("request timeout exceeded"), true},
{fmt.Errorf("temporary failure in name resolution"), true},
{fmt.Errorf("unexpected EOF"), true},
{fmt.Errorf("permission denied"), false},
{fmt.Errorf("file not found"), false},
}
for _, tt := range tests {
got := isRetriable(tt.err)
if got != tt.want {
t.Errorf("isRetriable(%q) = %v, want %v", tt.err, got, tt.want)
}
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ func (c *Client) FetchAllReleases() ([]VersionEntry, error) {
versions = append(versions, VersionEntry{
Version: stripV(gh.TagName),
Date: formatDate(gh.PublishedAt),
Changelog: truncateBody(gh.Body, 80),
Changelog: gh.Body,
Prerelease: gh.Prerelease,
})
}
+230
View File
@@ -228,6 +228,236 @@ func TestRateLimitError(t *testing.T) {
}
}
func TestFetchLatestRelease_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
_, err := client.FetchLatestRelease()
if err == nil {
t.Fatal("expected error for 404")
}
if !contains(err.Error(), "404") {
t.Errorf("error = %q, want to contain '404'", err.Error())
}
}
func TestFetchReleaseByTag_InvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{invalid json"))
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
_, err := client.FetchReleaseByTag("v1.0.0")
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestFetchLatestRelease_FieldMapping(t *testing.T) {
release := GitHubRelease{
TagName: "v2.1.0",
Body: "## Changelog\n* abc1234 - add new feature\n* def5678 Merge branch 'main'",
Prerelease: true,
PublishedAt: "2026-06-15T14:30:00Z",
HTMLURL: "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tag/v2.1.0",
Assets: []GitHubAsset{},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(release)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
info, err := client.FetchLatestRelease()
if err != nil {
t.Fatalf("error = %v", err)
}
if info.Version != "2.1.0" {
t.Errorf("Version = %q, want %q", info.Version, "2.1.0")
}
if info.Date != "2026-06-15" {
t.Errorf("Date = %q, want %q", info.Date, "2026-06-15")
}
if !info.Prerelease {
t.Error("Prerelease = false, want true")
}
if info.HTMLURL != release.HTMLURL {
t.Errorf("HTMLURL = %q, want %q", info.HTMLURL, release.HTMLURL)
}
if info.Changelog == "" {
t.Error("Changelog should not be empty")
}
}
func TestFetchAllReleases_EmptyList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]GitHubRelease{})
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
versions, err := client.FetchAllReleases()
if err != nil {
t.Fatalf("error = %v", err)
}
if len(versions) != 0 {
t.Errorf("len = %d, want 0", len(versions))
}
}
func TestFetchAllReleases_AllDrafts(t *testing.T) {
releases := []GitHubRelease{
{TagName: "v1.0.0", Draft: true},
{TagName: "v0.9.0", Draft: true},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(releases)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
versions, err := client.FetchAllReleases()
if err != nil {
t.Fatalf("error = %v", err)
}
if len(versions) != 0 {
t.Errorf("all-drafts: len = %d, want 0", len(versions))
}
}
func TestFetchReleaseByTag_WithVPrefix(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expected := "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tags/v1.0.5"
if r.URL.Path != expected {
t.Errorf("path = %q, want %q", r.URL.Path, expected)
}
json.NewEncoder(w).Encode(GitHubRelease{TagName: "v1.0.5", PublishedAt: "2026-01-01T00:00:00Z"})
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
info, err := client.FetchReleaseByTag("v1.0.5") // already has "v"
if err != nil {
t.Fatalf("error = %v", err)
}
if info.Version != "1.0.5" {
t.Errorf("Version = %q, want %q", info.Version, "1.0.5")
}
}
func TestFindChecksumsAsset_NotFound(t *testing.T) {
assets := []GitHubAsset{{Name: "dws-darwin-arm64.tar.gz"}}
if got := FindChecksumsAsset(assets); got != nil {
t.Errorf("FindChecksumsAsset() = %v, want nil", got)
}
}
func TestFindBinaryAssetFor_EmptyAssets(t *testing.T) {
_, err := FindBinaryAssetFor(nil, "darwin", "arm64")
if err == nil {
t.Error("expected error for empty assets")
}
}
func TestStripV(t *testing.T) {
tests := []struct{ in, want string }{
{"v1.0.0", "1.0.0"},
{"1.0.0", "1.0.0"},
{"v", ""},
{"", ""},
}
for _, tt := range tests {
got := stripV(tt.in)
if got != tt.want {
t.Errorf("stripV(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestFormatDate(t *testing.T) {
tests := []struct{ in, want string }{
{"2026-04-01T09:29:05Z", "2026-04-01"},
{"invalid-date", "invalid-date"},
{"", ""},
}
for _, tt := range tests {
got := formatDate(tt.in)
if got != tt.want {
t.Errorf("formatDate(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTruncateBody(t *testing.T) {
tests := []struct {
body string
maxLen int
want string
}{
{"short", 100, "short"},
{"line1\nline2", 100, "line1"},
{"a very long string here", 10, "a very ..."},
{"", 10, ""},
}
for _, tt := range tests {
got := truncateBody(tt.body, tt.maxLen)
if got != tt.want {
t.Errorf("truncateBody(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
}
}
}
func TestGithubToken(t *testing.T) {
// Clean env first
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("GH_TOKEN", "")
if got := githubToken(); got != "" {
t.Errorf("expected empty token, got %q", got)
}
t.Setenv("GITHUB_TOKEN", "gh-tok-123")
if got := githubToken(); got != "gh-tok-123" {
t.Errorf("expected gh-tok-123, got %q", got)
}
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("GH_TOKEN", "gh-alt-456")
if got := githubToken(); got != "gh-alt-456" {
t.Errorf("expected gh-alt-456, got %q", got)
}
}
func TestGetJSON_SetsHeaders(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "test-token-abc")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ua := r.Header.Get("User-Agent"); ua != userAgent {
t.Errorf("User-Agent = %q, want %q", ua, userAgent)
}
if accept := r.Header.Get("Accept"); accept != "application/vnd.github+json" {
t.Errorf("Accept = %q, want application/vnd.github+json", accept)
}
if auth := r.Header.Get("Authorization"); auth != "token test-token-abc" {
t.Errorf("Authorization = %q, want %q", auth, "token test-token-abc")
}
json.NewEncoder(w).Encode(GitHubRelease{TagName: "v1.0.0", PublishedAt: "2026-01-01T00:00:00Z"})
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
_, err := client.FetchLatestRelease()
if err != nil {
t.Fatalf("error = %v", err)
}
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
+70 -14
View File
@@ -4,6 +4,7 @@
package upgrade
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -42,6 +43,50 @@ var skillDirBlacklist = []string{
".real",
}
// SkillDirStatus describes the installation outcome for a single skill directory.
type SkillDirStatus int
const (
SkillDirOK SkillDirStatus = iota // successfully installed
SkillDirSkipped // agent not detected, directory skipped
SkillDirBlacklisted // blacklisted, never touched
SkillDirFailed // installation attempted but failed
)
// SkillDirResult holds the per-directory install result.
type SkillDirResult struct {
Dir string // destination directory (e.g. ~/.claude/skills/dws)
Status SkillDirStatus // outcome
Err error // non-nil when Status == SkillDirFailed
}
// SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call.
type SkillUpgradeResult struct {
Results []SkillDirResult
}
// Succeeded returns directories that were successfully updated.
func (r *SkillUpgradeResult) Succeeded() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirOK {
out = append(out, d)
}
}
return out
}
// Failed returns directories where installation was attempted but failed.
func (r *SkillUpgradeResult) Failed() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirFailed {
out = append(out, d)
}
}
return out
}
// UpgradeSkillLocations installs skills from extractedDir into all locations
// where they are currently installed or expected.
//
@@ -51,49 +96,60 @@ var skillDirBlacklist = []string{
// directory exists (e.g. ~/.claude/ exists => user has Claude)
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
//
// Returns the list of directories that were updated.
func UpgradeSkillLocations(extractedDir string) ([]string, error) {
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
var updated []string
result := &SkillUpgradeResult{}
for i, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
destDir := filepath.Join(homeDir, agentDir, "dws")
if i > 0 {
// For non-primary dirs, only update when the agent's parent dir exists.
// e.g. for ".claude/skills", check if ~/.claude exists.
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
}
os.RemoveAll(destDir)
if err := copyDir(extractedDir, destDir); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
updated = append(updated, destDir)
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
// Fallback: ensure at least one location has skills
if len(updated) == 0 {
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 {
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
os.MkdirAll(filepath.Dir(dest), dirPermShared)
if err := copyDir(extractedDir, dest); err != nil {
return nil, err
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == dest {
result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK})
}
updated = append(updated, dest)
}
return updated, nil
return result, nil
}
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
+149 -13
View File
@@ -59,58 +59,194 @@ func TestLocateSkillMD(t *testing.T) {
}
func TestUpgradeSkillLocations(t *testing.T) {
// Create a temp skill source
skillSrc := t.TempDir()
os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# test skill"), 0644)
os.MkdirAll(filepath.Join(skillSrc, "references"), 0755)
os.WriteFile(filepath.Join(skillSrc, "references", "guide.md"), []byte("# guide"), 0644)
// Test the function installs at least to the primary location
updated, err := UpgradeSkillLocations(skillSrc)
result, err := UpgradeSkillLocations(skillSrc)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if len(updated) == 0 {
t.Fatal("UpgradeSkillLocations() returned 0 updated locations")
succeeded := result.Succeeded()
if len(succeeded) == 0 {
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
}
// Verify primary location has the files
homeDir, _ := os.UserHomeDir()
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
found := false
for _, u := range updated {
if u == primaryDest {
for _, d := range succeeded {
if d.Dir == primaryDest {
found = true
break
}
}
if !found {
t.Errorf("primary location %s not in updated list: %v", primaryDest, updated)
dirs := make([]string, len(succeeded))
for i, d := range succeeded {
dirs[i] = d.Dir
}
t.Errorf("primary location %s not in succeeded list: %v", primaryDest, dirs)
}
// Verify SKILL.md was copied
skillMDPath := filepath.Join(primaryDest, "SKILL.md")
if _, err := os.Stat(skillMDPath); os.IsNotExist(err) {
t.Errorf("SKILL.md not found at %s", skillMDPath)
}
// Verify references/ was copied
guidePath := filepath.Join(primaryDest, "references", "guide.md")
if _, err := os.Stat(guidePath); os.IsNotExist(err) {
t.Errorf("references/guide.md not found at %s", guidePath)
}
// Cleanup
// Verify that failed list is empty for a normal install
if len(result.Failed()) != 0 {
t.Errorf("expected 0 failures, got %d", len(result.Failed()))
}
// Verify total results contain at least one entry for each known (non-blacklisted) dir
if len(result.Results) == 0 {
t.Error("expected non-empty Results")
}
os.RemoveAll(primaryDest)
}
func TestBlacklistPreventsRealDir(t *testing.T) {
// Ensure .real is not in knownSkillDirs (it shouldn't be, but verify)
for _, dir := range knownSkillDirs {
if isBlacklisted(dir) {
t.Errorf("knownSkillDirs contains blacklisted entry: %q", dir)
}
}
}
func TestSkillUpgradeResult_SucceededAndFailed(t *testing.T) {
result := &SkillUpgradeResult{
Results: []SkillDirResult{
{Dir: "/a", Status: SkillDirOK},
{Dir: "/b", Status: SkillDirFailed, Err: os.ErrPermission},
{Dir: "/c", Status: SkillDirSkipped},
{Dir: "/d", Status: SkillDirBlacklisted},
{Dir: "/e", Status: SkillDirOK},
},
}
succeeded := result.Succeeded()
if len(succeeded) != 2 {
t.Errorf("Succeeded() len = %d, want 2", len(succeeded))
}
if succeeded[0].Dir != "/a" || succeeded[1].Dir != "/e" {
t.Errorf("Succeeded() dirs = %v, %v", succeeded[0].Dir, succeeded[1].Dir)
}
failed := result.Failed()
if len(failed) != 1 {
t.Errorf("Failed() len = %d, want 1", len(failed))
}
if failed[0].Dir != "/b" {
t.Errorf("Failed()[0].Dir = %q, want /b", failed[0].Dir)
}
if failed[0].Err != os.ErrPermission {
t.Errorf("Failed()[0].Err = %v, want ErrPermission", failed[0].Err)
}
}
func TestSkillUpgradeResult_EmptyResults(t *testing.T) {
result := &SkillUpgradeResult{}
if len(result.Succeeded()) != 0 {
t.Error("empty Succeeded should be nil/empty")
}
if len(result.Failed()) != 0 {
t.Error("empty Failed should be nil/empty")
}
}
func TestSkillDirStatusConstants(t *testing.T) {
if SkillDirOK != 0 {
t.Errorf("SkillDirOK = %d, want 0", SkillDirOK)
}
if SkillDirSkipped != 1 {
t.Errorf("SkillDirSkipped = %d, want 1", SkillDirSkipped)
}
if SkillDirBlacklisted != 2 {
t.Errorf("SkillDirBlacklisted = %d, want 2", SkillDirBlacklisted)
}
if SkillDirFailed != 3 {
t.Errorf("SkillDirFailed = %d, want 3", SkillDirFailed)
}
}
func TestEnsureUpgradeDirectories(t *testing.T) {
// This test verifies the structure is correct without actually modifying $HOME
homeDir, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot get home dir")
}
err = EnsureUpgradeDirectories()
if err != nil {
t.Fatalf("EnsureUpgradeDirectories() error = %v", err)
}
expectedDirs := []string{
filepath.Join(homeDir, ".dws"),
filepath.Join(homeDir, ".dws", "data"),
filepath.Join(homeDir, ".dws", "data", "backups"),
filepath.Join(homeDir, ".dws", "cache"),
filepath.Join(homeDir, ".dws", "cache", "downloads"),
}
for _, d := range expectedDirs {
info, err := os.Stat(d)
if err != nil {
t.Errorf("directory %s should exist: %v", d, err)
continue
}
if !info.IsDir() {
t.Errorf("%s should be a directory", d)
}
}
}
func TestDownloadCacheDir(t *testing.T) {
dir := DownloadCacheDir()
if dir == "" {
t.Error("DownloadCacheDir() returned empty")
}
if !filepath.IsAbs(dir) {
t.Errorf("DownloadCacheDir() = %q, want absolute path", dir)
}
}
func TestBinaryName(t *testing.T) {
name := BinaryName()
if name != "dws" && name != "dws.exe" {
t.Errorf("BinaryName() = %q, want dws or dws.exe", name)
}
}
func TestLocateSkillMD_NestedBeatsFlat(t *testing.T) {
dir := t.TempDir()
// Create both flat and nested layouts; nested should take priority
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("flat"), 0644)
os.MkdirAll(filepath.Join(dir, "dws"), 0755)
os.WriteFile(filepath.Join(dir, "dws", "SKILL.md"), []byte("nested"), 0644)
result := LocateSkillMD(dir)
want := filepath.Join(dir, "dws")
if result != want {
t.Errorf("LocateSkillMD() = %q, want nested %q", result, want)
}
}
func TestKnownSkillDirsNotEmpty(t *testing.T) {
if len(knownSkillDirs) == 0 {
t.Error("knownSkillDirs is empty")
}
// First entry should be the primary location
if knownSkillDirs[0] != ".agents/skills" {
t.Errorf("first knownSkillDir = %q, want .agents/skills", knownSkillDirs[0])
}
}
+67 -10
View File
@@ -9,16 +9,19 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
// ReplaceSelf atomically replaces the currently running binary with newBinaryPath.
// Strategy:
// 1. Resolve current exe path (follow symlinks)
// 2. Set correct permissions (0755) on new binary
// 3. Try atomic os.Rename (same filesystem, instant swap)
// 4. Fallback to copy for cross-device or Windows locks
// 5. Sync to disk for durability
//
// On Unix (macOS / Linux):
// 1. Try atomic os.Rename (same filesystem, instant swap)
// 2. Fallback to copy if cross-device
//
// On Windows the running .exe is locked by the OS, so direct overwrite fails.
// Strategy: rename running exe → .old, then rename/copy new binary in, then
// clean up .old (best-effort, may be cleaned on next run).
func ReplaceSelf(newBinaryPath string) error {
currentExe, err := os.Executable()
if err != nil {
@@ -33,16 +36,70 @@ func ReplaceSelf(newBinaryPath string) error {
return fmt.Errorf("设置权限失败: %w", err)
}
if err := os.Rename(newBinaryPath, currentExe); err != nil {
if err := copyFile(newBinaryPath, currentExe, filePermBinary); err != nil {
return err
}
if err := replaceExeFile(newBinaryPath, currentExe); err != nil {
return err
}
syncParentDir(currentExe)
return nil
}
// replaceExeFile replaces dst with src, handling Windows file-lock semantics.
func replaceExeFile(src, dst string) error {
// Fast path: atomic rename (works on Unix same-filesystem, or Windows when dst is unlocked)
if err := os.Rename(src, dst); err == nil {
return nil
}
if runtime.GOOS == "windows" {
return windowsReplace(src, dst)
}
// Unix cross-device fallback
return copyFile(src, dst, filePermBinary)
}
// windowsReplace handles the Windows-specific case where the running exe is locked.
// Windows allows renaming a running executable but not overwriting it.
func windowsReplace(src, dst string) error {
oldPath := dst + ".old"
// Clean up leftover .old from a previous upgrade
os.Remove(oldPath)
// Move the running (locked) binary out of the way
if err := os.Rename(dst, oldPath); err != nil {
return fmt.Errorf("无法移动正在运行的二进制文件: %w", err)
}
// Place the new binary at the target path
if err := os.Rename(src, dst); err != nil {
// Cross-device fallback
if cpErr := copyFile(src, dst, filePermBinary); cpErr != nil {
// Attempt to restore the original
os.Rename(oldPath, dst)
return fmt.Errorf("替换失败: %w", cpErr)
}
}
// Best-effort cleanup; the .old file may still be locked and will be
// removed on the next upgrade or reboot.
os.Remove(oldPath)
return nil
}
// CleanupStaleFiles removes leftover .old and .rollback-tmp files from
// previous upgrades (relevant on Windows where locked files cannot be
// deleted immediately).
func CleanupStaleFiles() {
exe, err := CurrentBinaryPath()
if err != nil {
return
}
os.Remove(exe + ".old")
os.Remove(exe + ".rollback-tmp")
}
// ExtractZip unzips zipPath contents into targetDir.
// Contains zip-slip protection against path traversal attacks.
func ExtractZip(zipPath, targetDir string) error {
+264
View File
@@ -0,0 +1,264 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func TestExtractZip_BasicFiles(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "test.zip")
targetDir := filepath.Join(dir, "extracted")
createTestZip(t, zipPath, map[string]string{
"README.md": "# Hello",
"dws/SKILL.md": "# Skill",
"dws/data.txt": "some data",
})
if err := ExtractZip(zipPath, targetDir); err != nil {
t.Fatalf("ExtractZip() error = %v", err)
}
assertFileContent(t, filepath.Join(targetDir, "README.md"), "# Hello")
assertFileContent(t, filepath.Join(targetDir, "dws", "SKILL.md"), "# Skill")
assertFileContent(t, filepath.Join(targetDir, "dws", "data.txt"), "some data")
}
func TestExtractZip_ZipSlipProtection(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "evil.zip")
targetDir := filepath.Join(dir, "extracted")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
// Attempt path traversal
ew, _ := w.Create("../../etc/passwd")
ew.Write([]byte("root:x:0:0"))
// Normal file
nw, _ := w.Create("normal.txt")
nw.Write([]byte("ok"))
w.Close()
f.Close()
if err := ExtractZip(zipPath, targetDir); err != nil {
t.Fatalf("ExtractZip() error = %v", err)
}
// Path-traversal entry should be skipped
evilPath := filepath.Join(dir, "..", "etc", "passwd")
if _, err := os.Stat(evilPath); err == nil {
t.Error("zip-slip protection failed: traversal file was extracted")
}
// Normal file should exist
assertFileContent(t, filepath.Join(targetDir, "normal.txt"), "ok")
}
func TestExtractZip_InvalidZip(t *testing.T) {
dir := t.TempDir()
badPath := filepath.Join(dir, "bad.zip")
os.WriteFile(badPath, []byte("not a zip"), 0644)
err := ExtractZip(badPath, filepath.Join(dir, "out"))
if err == nil {
t.Error("ExtractZip() expected error for invalid zip")
}
}
func TestExtractZip_EmptyZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "empty.zip")
createTestZip(t, zipPath, map[string]string{})
targetDir := filepath.Join(dir, "extracted")
if err := ExtractZip(zipPath, targetDir); err != nil {
t.Fatalf("ExtractZip() error = %v for empty zip", err)
}
}
func TestFindBinaryInDir_TopLevel(t *testing.T) {
dir := t.TempDir()
dwsPath := filepath.Join(dir, "dws")
os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
got := FindBinaryInDir(dir)
if got != dwsPath {
t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
}
}
func TestFindBinaryInDir_Nested(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "subdir")
os.MkdirAll(nested, 0755)
dwsPath := filepath.Join(nested, "dws")
os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
got := FindBinaryInDir(dir)
if got != dwsPath {
t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
}
}
func TestFindBinaryInDir_Windows(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "dws.exe")
os.WriteFile(exePath, []byte("MZ"), 0755)
got := FindBinaryInDir(dir)
if got != exePath {
t.Errorf("FindBinaryInDir() = %q, want %q", got, exePath)
}
}
func TestFindBinaryInDir_NotFound(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "other.bin"), []byte("x"), 0755)
got := FindBinaryInDir(dir)
if got != "" {
t.Errorf("FindBinaryInDir() = %q, want empty", got)
}
}
func TestCopyFile(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
os.WriteFile(src, []byte("copy me"), 0644)
if err := copyFile(src, dst, 0755); err != nil {
t.Fatalf("copyFile() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "copy me" {
t.Errorf("dst content = %q, want %q", string(got), "copy me")
}
info, _ := os.Stat(dst)
if info.Mode().Perm()&0755 != 0755 {
t.Errorf("dst perm = %o, want 0755", info.Mode().Perm())
}
}
func TestCopyFile_SrcNotExist(t *testing.T) {
dir := t.TempDir()
err := copyFile(filepath.Join(dir, "missing"), filepath.Join(dir, "dst"), 0644)
if err == nil {
t.Error("copyFile() expected error for missing src")
}
}
func TestReplaceExeFile_AtomicRename(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "new-binary")
dst := filepath.Join(dir, "dws")
os.WriteFile(dst, []byte("old"), 0755)
os.WriteFile(src, []byte("new"), 0755)
if err := replaceExeFile(src, dst); err != nil {
t.Fatalf("replaceExeFile() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "new" {
t.Errorf("dst content = %q, want %q", string(got), "new")
}
// src should no longer exist (it was renamed)
if _, err := os.Stat(src); err == nil {
t.Error("src should have been renamed away")
}
}
func TestWindowsReplace_Basic(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "new-binary")
dst := filepath.Join(dir, "dws.exe")
os.WriteFile(dst, []byte("old-exe"), 0755)
os.WriteFile(src, []byte("new-exe"), 0755)
if err := windowsReplace(src, dst); err != nil {
t.Fatalf("windowsReplace() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "new-exe" {
t.Errorf("dst content = %q, want %q", string(got), "new-exe")
}
// .old should have been cleaned up
if _, err := os.Stat(dst + ".old"); err == nil {
t.Error(".old file should have been cleaned up")
}
}
func TestWindowsReplace_CleansUpStaleOld(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "new-binary")
dst := filepath.Join(dir, "dws.exe")
oldPath := dst + ".old"
os.WriteFile(dst, []byte("current"), 0755)
os.WriteFile(src, []byte("newer"), 0755)
os.WriteFile(oldPath, []byte("stale-old"), 0755)
if err := windowsReplace(src, dst); err != nil {
t.Fatalf("windowsReplace() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "newer" {
t.Errorf("dst content = %q, want %q", string(got), "newer")
}
}
func TestCleanupStaleFiles(t *testing.T) {
// Just verify it doesn't panic; the actual files to clean depend on os.Executable()
CleanupStaleFiles()
}
// --- helpers ---
func createTestZip(t *testing.T, zipPath string, files map[string]string) {
t.Helper()
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte(content))
}
w.Close()
f.Close()
}
func assertFileContent(t *testing.T, path, want string) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil {
t.Errorf("cannot read %s: %v", path, err)
return
}
if string(got) != want {
t.Errorf("content of %s = %q, want %q", path, string(got), want)
}
}
+8 -10
View File
@@ -114,6 +114,7 @@ func (r *RollbackManager) Rollback() error {
}
// RollbackTo restores a specific backup.
// Uses replaceExeFile to handle Windows file-lock semantics correctly.
func (r *RollbackManager) RollbackTo(backup BackupInfo) error {
currentExe, err := os.Executable()
if err != nil {
@@ -126,24 +127,21 @@ func (r *RollbackManager) RollbackTo(backup BackupInfo) error {
binaryBackupPath := backup.BinaryPath
if binaryBackupPath == "" {
binaryBackupPath = filepath.Join(backup.Path, "binary", "dws")
binaryBackupPath = filepath.Join(backup.Path, "binary", BinaryName())
}
if _, err := os.Stat(binaryBackupPath); os.IsNotExist(err) {
return fmt.Errorf("备份文件不存在: %s", binaryBackupPath)
}
backupFileInfo, err := os.Stat(binaryBackupPath)
if err != nil {
return fmt.Errorf("无法读取备份文件信息: %w", err)
// Copy backup to a temp file first so replaceExeFile can use rename
tmpPath := currentExe + ".rollback-tmp"
if err := copyFile(binaryBackupPath, tmpPath, filePermBinary); err != nil {
return fmt.Errorf("准备回滚文件失败: %w", err)
}
perm := backupFileInfo.Mode()
if perm&0111 == 0 {
perm = filePermBinary
}
if err := copyFile(binaryBackupPath, currentExe, perm); err != nil {
if err := replaceExeFile(tmpPath, currentExe); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("恢复二进制失败: %w", err)
}
+257
View File
@@ -75,6 +75,263 @@ func TestRollbackManagerCleanup(t *testing.T) {
}
}
func TestRollbackManagerCleanup_KeepAll(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
for i := 0; i < 3; i++ {
name := "v1.0." + string(rune('0'+i)) + "-20260101-01000" + string(rune('0'+i))
backupDir := filepath.Join(dir, name)
os.MkdirAll(backupDir, 0755)
rm.saveBackupInfo(BackupInfo{
Path: backupDir,
Version: "1.0." + string(rune('0'+i)),
CreatedAt: mustParseTime(t, "2026-01-0"+string(rune('1'+i))+"T01:00:00Z"),
})
}
if err := rm.Cleanup(10); err != nil {
t.Fatalf("Cleanup(10) error = %v", err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 3 {
t.Errorf("after Cleanup(10), %d entries remain, want 3", len(entries))
}
}
func TestRollbackManagerCleanup_EmptyDir(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
if err := rm.Cleanup(1); err != nil {
t.Fatalf("Cleanup on empty dir error = %v", err)
}
}
func TestListBackups_WithInfoJSON(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
backupDir := filepath.Join(dir, "v1.0.5-20260315-120000")
os.MkdirAll(filepath.Join(backupDir, "binary"), 0755)
os.WriteFile(filepath.Join(backupDir, "binary", "dws"), []byte("#!/bin/sh"), 0755)
info := BackupInfo{
Path: backupDir,
BinaryPath: filepath.Join(backupDir, "binary", "dws"),
Version: "1.0.5",
CreatedAt: mustParseTime(t, "2026-03-15T12:00:00Z"),
Size: 1234,
}
rm.saveBackupInfo(info)
backups, err := rm.ListBackups()
if err != nil {
t.Fatalf("ListBackups() error = %v", err)
}
if len(backups) != 1 {
t.Fatalf("ListBackups() len = %d, want 1", len(backups))
}
if backups[0].Version != "1.0.5" {
t.Errorf("Version = %q, want %q", backups[0].Version, "1.0.5")
}
if backups[0].Size != 1234 {
t.Errorf("Size = %d, want 1234", backups[0].Size)
}
if backups[0].BinaryPath == "" {
t.Error("BinaryPath should be set from info.json")
}
}
func TestListBackups_WithoutInfoJSON(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
// Create a backup dir without info.json => should fallback to name parsing
backupDir := filepath.Join(dir, "v1.0.3-20260101-100000")
os.MkdirAll(backupDir, 0755)
backups, err := rm.ListBackups()
if err != nil {
t.Fatalf("ListBackups() error = %v", err)
}
if len(backups) != 1 {
t.Fatalf("len = %d, want 1", len(backups))
}
if backups[0].Version != "1.0.3" {
t.Errorf("fallback Version = %q, want 1.0.3", backups[0].Version)
}
}
func TestListBackups_SortOrder(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
timestamps := []string{
"2026-01-01T01:00:00Z",
"2026-03-15T12:00:00Z",
"2026-02-10T06:00:00Z",
}
for i, ts := range timestamps {
name := "v1.0." + string(rune('0'+i)) + "-backup"
backupDir := filepath.Join(dir, name)
os.MkdirAll(backupDir, 0755)
rm.saveBackupInfo(BackupInfo{
Path: backupDir,
Version: "1.0." + string(rune('0'+i)),
CreatedAt: mustParseTime(t, ts),
})
}
backups, err := rm.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(backups) != 3 {
t.Fatalf("len = %d", len(backups))
}
// Should be sorted newest first
if backups[0].Version != "1.0.1" {
t.Errorf("newest = %q, want 1.0.1 (March)", backups[0].Version)
}
if backups[2].Version != "1.0.0" {
t.Errorf("oldest = %q, want 1.0.0 (January)", backups[2].Version)
}
}
func TestListBackups_NonexistentDir(t *testing.T) {
rm := NewRollbackManagerWithDir("/tmp/nonexistent-dir-" + time.Now().Format("20060102150405"))
backups, err := rm.ListBackups()
if err != nil {
t.Fatalf("error = %v (should return nil for nonexistent dir)", err)
}
if backups != nil {
t.Errorf("should return nil for nonexistent dir, got %d entries", len(backups))
}
}
func TestListBackups_SkipsRegularFiles(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
// Create a regular file (not a directory) — should be ignored
os.WriteFile(filepath.Join(dir, "not-a-backup.txt"), []byte("ignore"), 0644)
// Create an actual backup directory
backupDir := filepath.Join(dir, "v1.0.0-20260101-000000")
os.MkdirAll(backupDir, 0755)
rm.saveBackupInfo(BackupInfo{
Path: backupDir,
Version: "1.0.0",
CreatedAt: mustParseTime(t, "2026-01-01T00:00:00Z"),
})
backups, err := rm.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(backups) != 1 {
t.Errorf("len = %d, want 1 (file should be ignored)", len(backups))
}
}
func TestSaveAndLoadBackupInfo(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
backupDir := filepath.Join(dir, "v1.0.0-test")
os.MkdirAll(backupDir, 0755)
original := BackupInfo{
Path: backupDir,
BinaryPath: filepath.Join(backupDir, "binary", "dws"),
Version: "1.0.0",
CreatedAt: mustParseTime(t, "2026-06-01T10:00:00Z"),
Size: 5678,
}
rm.saveBackupInfo(original)
loaded, err := rm.loadBackupInfo(backupDir)
if err != nil {
t.Fatalf("loadBackupInfo() error = %v", err)
}
if loaded.Version != original.Version {
t.Errorf("Version = %q, want %q", loaded.Version, original.Version)
}
if loaded.Size != original.Size {
t.Errorf("Size = %d, want %d", loaded.Size, original.Size)
}
if loaded.BinaryPath != original.BinaryPath {
t.Errorf("BinaryPath = %q, want %q", loaded.BinaryPath, original.BinaryPath)
}
}
func TestLoadBackupInfo_InvalidJSON(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
backupDir := filepath.Join(dir, "v1.0.0-bad")
os.MkdirAll(backupDir, 0755)
os.WriteFile(filepath.Join(backupDir, "info.json"), []byte("{bad json"), 0644)
_, err := rm.loadBackupInfo(backupDir)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestNewRollbackManager(t *testing.T) {
rm := NewRollbackManager()
if rm.backupDir == "" {
t.Error("backupDir should not be empty")
}
if rm.maxBackups != defaultMaxBackups {
t.Errorf("maxBackups = %d, want %d", rm.maxBackups, defaultMaxBackups)
}
}
func TestCopyDir(t *testing.T) {
src := t.TempDir()
os.WriteFile(filepath.Join(src, "file1.txt"), []byte("content1"), 0644)
os.MkdirAll(filepath.Join(src, "sub"), 0755)
os.WriteFile(filepath.Join(src, "sub", "file2.txt"), []byte("content2"), 0644)
dst := filepath.Join(t.TempDir(), "copy")
if err := copyDir(src, dst); err != nil {
t.Fatalf("copyDir() error = %v", err)
}
got1, _ := os.ReadFile(filepath.Join(dst, "file1.txt"))
if string(got1) != "content1" {
t.Errorf("file1 = %q, want content1", string(got1))
}
got2, _ := os.ReadFile(filepath.Join(dst, "sub", "file2.txt"))
if string(got2) != "content2" {
t.Errorf("sub/file2 = %q, want content2", string(got2))
}
}
func TestCopyDir_EmptySrc(t *testing.T) {
src := t.TempDir()
dst := filepath.Join(t.TempDir(), "copy")
if err := copyDir(src, dst); err != nil {
t.Fatalf("copyDir() error = %v", err)
}
entries, _ := os.ReadDir(dst)
if len(entries) != 0 {
t.Errorf("dst should be empty, got %d entries", len(entries))
}
}
func TestCopyDir_SrcNotExist(t *testing.T) {
err := copyDir("/tmp/nonexistent-src-"+time.Now().Format("150405"), t.TempDir())
if err == nil {
t.Error("expected error for nonexistent src")
}
}
func mustParseTime(t *testing.T, s string) time.Time {
t.Helper()
ts, err := time.Parse(time.RFC3339, s)