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