diff --git a/README.md b/README.md
index 09ca9ac0..558d6430 100644
--- a/README.md
+++ b/README.md
@@ -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
+## 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
+```
+
+
+How it works
+
+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 |
+
+
+
## Getting Started
```bash
diff --git a/README_zh.md b/README_zh.md
index d650aa7c..65a82526 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -28,6 +28,7 @@
- [为什么选择 dws?](#why-dws)
- [安装](#安装)
+- [升级](#升级)
- [开始使用](#开始使用)
- [快速开始](#快速开始)
- [在 Agent 中使用](#在-agent-中使用)
@@ -85,6 +86,41 @@ cp dws ~/.local/bin/ # 安装到 PATH
+## 升级
+
+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 # 跳过确认直接升级
+```
+
+
+工作原理
+
+升级过程采用两阶段原子流程,确保一致性:
+
+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` | 跳过确认提示 |
+
+
+
## 开始使用
```bash
diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go
index 08d18d85..b0a0345c 100644
--- a/internal/app/upgrade.go
+++ b/internal/app/upgrade.go
@@ -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 安装指定版本")
+ 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 {
diff --git a/internal/app/upgrade_test.go b/internal/app/upgrade_test.go
new file mode 100644
index 00000000..517e5506
--- /dev/null
+++ b/internal/app/upgrade_test.go
@@ -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")
+ }
+}
diff --git a/internal/upgrade/downloader.go b/internal/upgrade/downloader.go
index 009fa440..38584429 100644
--- a/internal/upgrade/downloader.go
+++ b/internal/upgrade/downloader.go
@@ -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
}
diff --git a/internal/upgrade/downloader_test.go b/internal/upgrade/downloader_test.go
new file mode 100644
index 00000000..6a8cf0a2
--- /dev/null
+++ b/internal/upgrade/downloader_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/upgrade/github.go b/internal/upgrade/github.go
index 90c6beb1..12671474 100644
--- a/internal/upgrade/github.go
+++ b/internal/upgrade/github.go
@@ -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,
})
}
diff --git a/internal/upgrade/github_test.go b/internal/upgrade/github_test.go
index 954428ad..a50fff96 100644
--- a/internal/upgrade/github_test.go
+++ b/internal/upgrade/github_test.go
@@ -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 {
diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go
index 1d093523..9bbe0678 100644
--- a/internal/upgrade/paths.go
+++ b/internal/upgrade/paths.go
@@ -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.
diff --git a/internal/upgrade/paths_test.go b/internal/upgrade/paths_test.go
index 631cdcb2..b2088f71 100644
--- a/internal/upgrade/paths_test.go
+++ b/internal/upgrade/paths_test.go
@@ -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])
+ }
+}
diff --git a/internal/upgrade/replacer.go b/internal/upgrade/replacer.go
index 729b4dbf..c4fd25fd 100644
--- a/internal/upgrade/replacer.go
+++ b/internal/upgrade/replacer.go
@@ -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 {
diff --git a/internal/upgrade/replacer_test.go b/internal/upgrade/replacer_test.go
new file mode 100644
index 00000000..14fd9244
--- /dev/null
+++ b/internal/upgrade/replacer_test.go
@@ -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)
+ }
+}
diff --git a/internal/upgrade/rollback.go b/internal/upgrade/rollback.go
index a436a5ff..9431b09b 100644
--- a/internal/upgrade/rollback.go
+++ b/internal/upgrade/rollback.go
@@ -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)
}
diff --git a/internal/upgrade/rollback_test.go b/internal/upgrade/rollback_test.go
index 2bb9eb93..74c34b43 100644
--- a/internal/upgrade/rollback_test.go
+++ b/internal/upgrade/rollback_test.go
@@ -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)