feat: add self-upgrade command with GitHub Releases, atomic flow, and cross-platform support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user