// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 package app import ( "context" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) var ( ugBold = tui.Bold ugGreen = tui.Success ugYellow = tui.Warning ugRed = tui.Danger ugCyan = tui.Cyan ugDim = tui.Dim ugBoldGrn = tui.Success ) const defaultListLimit = 10 func newUpgradeCommand() *cobra.Command { var ( flagCheck bool flagList bool flagVersion string flagRollback bool flagForce bool flagSkipSkills bool flagAll bool ) cmd := &cobra.Command{ Use: "upgrade", Short: "升级 DWS CLI 到最新版本", Long: `检查并升级 DWS CLI 到最新版本。 自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。 升级前会自动备份当前版本,可通过 --rollback 回滚。`, Example: ` dws upgrade # 交互式升级到最新版本 dws upgrade --check # 仅检查是否有新版本 dws upgrade --list # 列出最近版本 dws upgrade --list --all # 列出所有版本 dws upgrade --version v1.0.5 # 升级到指定版本 dws upgrade --rollback # 回滚到上一版本 dws upgrade --dry-run # 仅预览升级步骤,不实际执行 dws upgrade -y # 跳过确认直接升级`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if h := edition.Get(); h != nil && h.IsEmbedded { name := h.Name if name == "" { name = "embedded" } return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name) } yes, _ := cmd.Flags().GetBool("yes") dryRun, _ := cmd.Flags().GetBool("dry-run") format := resolveUpgradeFormat(cmd) if flagList { limit := defaultListLimit if flagAll { limit = 0 } return runUpgradeList(cmd, format, limit) } if flagRollback { return runUpgradeRollback(yes) } if flagCheck { return runUpgradeCheck(cmd, format) } return runUpgrade(cmd.Context(), upgradeOptions{ targetVersion: flagVersion, force: flagForce, skipSkills: flagSkipSkills, yes: yes, dryRun: dryRun, }) }, } cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本") cmd.Flags().BoolVar(&flagList, "list", false, "列出可用版本") cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所有版本") cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本") cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本") cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本") cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新") return cmd } type upgradeOptions struct { targetVersion string force bool skipSkills bool yes bool dryRun bool } // --- dws upgrade --check --- 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 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.Println() fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+latest.Version)) if latest.Date != "" { fmt.Printf(" %s %s\n", ugBold("发布日期: "), latest.Date) } if latest.Prerelease { fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release")) } 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.Printf(" %s\n", ugDim("运行 dws upgrade 进行升级")) return nil } // --- dws upgrade --list --- // runUpgradeList displays available versions. When limit > 0, only the most // recent `limit` versions are shown; pass 0 to show all (--all flag). func runUpgradeList(cmd *cobra.Command, format string, limit int) error { client := upgrade.NewClient() if format != "json" { fmt.Printf(" %s\n", ugDim("获取版本列表...")) } versions, err := client.FetchAllReleases() if err != nil { return fmt.Errorf("获取版本列表失败: %w", err) } totalCount := len(versions) truncated := false if limit > 0 && len(versions) > limit { versions = versions[:limit] truncated = true } 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), }) } result := map[string]any{ "current_version": ensureV(version), "versions": items, "total": totalCount, } if truncated { result["truncated"] = true result["shown"] = limit } return writeJSON(cmd.OutOrStdout(), result) } if totalCount == 0 { fmt.Printf(" %s\n", ugYellow("未找到任何版本")) return nil } fmt.Println() fmt.Printf(" %s\n", ugBold(fmt.Sprintf("%-12s %-12s %-12s %s", "VERSION", "DATE", "TYPE", "CHANGELOG"))) fmt.Printf(" %s\n", ugDim(strings.Repeat("─", 70))) for _, v := range versions { releaseType := ugGreen("stable") if v.Prerelease { releaseType = ugYellow("pre-release") } versionStr := fmt.Sprintf("v%-11s", v.Version) marker := "" if v.Version == currentVer { versionStr = ugBoldGrn(versionStr) marker = ugCyan(" ← 已安装") } 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 %s\n", ugBold("当前版本:"), ugBoldGrn(ensureV(version))) if truncated { fmt.Printf(" %s\n", ugDim(fmt.Sprintf("显示最近 %d 个版本(共 %d 个),使用 --list --all 查看全部", limit, totalCount))) } fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.7 安装指定版本")) return nil } // --- dws upgrade --rollback --- func runUpgradeRollback(yes bool) error { rm := upgrade.NewRollbackManager() backups, err := rm.ListBackups() if err != nil { return fmt.Errorf("获取备份列表失败: %w", err) } if len(backups) == 0 { return fmt.Errorf("没有可用的备份,无法回滚") } 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.Printf("是否回滚到 %s? [y/N] ", ugBold(targetVer)) var answer string fmt.Scanln(&answer) if answer != "y" && answer != "Y" { fmt.Println("已取消") return nil } } fmt.Print(" 回滚中...") if err := rm.RollbackTo(target); err != nil { return fmt.Errorf("\n回滚失败: %w", err) } fmt.Printf(" %s\n", ugGreen("✓")) fmt.Println() 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. // writeDryRunPlan renders the steps that `dws upgrade` would perform, without // touching the filesystem. Kept side-effect-free and writer-injectable so the // --dry-run contract can be asserted in tests. func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills bool) { fmt.Fprintln(w) fmt.Fprintf(w, " %s 预览模式,不会下载或修改任何文件\n", ugBold("[dry-run]")) fmt.Fprintf(w, " 将执行以下操作:\n") fmt.Fprintf(w, " [1/5] 备份当前版本 %s\n", ugDim(ensureV(currentVer))) fmt.Fprintf(w, " [2/5] 下载 %s\n", ugCyan(binaryAssetName)) if hasSkills { fmt.Fprintf(w, " 下载 %s\n", ugCyan("dws-skills.zip")) } fmt.Fprintf(w, " [3/5] 校验 SHA256\n") fmt.Fprintf(w, " [4/5] 解压并验证\n") replaceStep := "替换二进制" if hasSkills { replaceStep += " 并安装技能包" } fmt.Fprintf(w, " [5/5] %s\n", replaceStep) fmt.Fprintln(w) fmt.Fprintf(w, " %s\n", ugDim("移除 --dry-run 以实际执行升级")) } func runUpgrade(ctx context.Context, opts upgradeOptions) error { 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(" 指定版本: %s\n", ugCyan("v"+opts.targetVersion)) release, err = client.FetchReleaseByTag(opts.targetVersion) if err != nil { return fmt.Errorf("获取版本 %s 信息失败: %w", opts.targetVersion, err) } } else { release, err = client.FetchLatestRelease() if err != nil { return fmt.Errorf("检查更新失败: %w", err) } } currentVer := version if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) { fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer))) return nil } fmt.Println() fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version)) if release.Date != "" { fmt.Printf(" %s %s\n", ugBold("发布日期: "), release.Date) } if release.Prerelease { fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release")) } // --dry-run: preview only. Resolve the platform asset so a missing build is // still reported, then describe the steps that *would* run and return before // any side effect (no backup, no download, no replace). Matches the global // flag's contract: "预览操作内容,不实际执行". if opts.dryRun { binaryAsset, err := upgrade.FindBinaryAsset(release.Assets) if err != nil { return err } hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills) return nil } if !opts.yes { fmt.Println() fmt.Printf("是否升级? [y/N] ") var answer string fmt.Scanln(&answer) if answer != "y" && answer != "Y" { fmt.Println("已取消") return nil } } binaryAsset, err := upgrade.FindBinaryAsset(release.Assets) if err != nil { return err } tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*") if err != nil { tmpDir, err = os.MkdirTemp("", "dws-upgrade-*") if err != nil { return fmt.Errorf("创建临时目录失败: %w", err) } } defer os.RemoveAll(tmpDir) 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() _, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v")) if backupErr != nil { fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr) } else { fmt.Printf(" %s\n", ugGreen("✓")) } // Fetch checksums.txt (needed for strict verification of both binary and skills) var checksumsContent string 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) } } } // --- 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 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent) }) if err != nil { fmt.Println() return fmt.Errorf("下载二进制失败: %w", err) } elapsed := time.Since(start) clearLine := strings.Repeat(" ", 100) 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) } 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 } } // --- 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 { fmt.Println() return fmt.Errorf("解压失败: %w", err) } } else { if err := extractTarGz(binaryArchivePath, extractDir); err != nil { fmt.Println() return fmt.Errorf("解压失败: %w", err) } } binaryPath := upgrade.FindBinaryInDir(extractDir) if binaryPath == "" { fmt.Println() return fmt.Errorf("在解压目录中未找到 dws 二进制文件") } if err := validateNewBinary(binaryPath, release.Version); err != nil { fmt.Println() return fmt.Errorf("验证失败: %w", err) } 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") } } fmt.Printf(" %s\n", ugGreen("✓")) // ======================================================================== // 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("✓")) } // Clear discovery-derived caches so the upgraded binary rebuilds its // command tree from a fresh fetch instead of inheriting snapshots written // by the old version — a poisoned snapshot used to lock out every // invocation before the build guards landed (#447 / #449). if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil { fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr))) } else if len(purged) > 0 { fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建")) } // Cleanup old backups rm.Cleanup(5) // Summary fmt.Println() 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 } // 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) // 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 } } // 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 } // No checksum info available at all fmt.Printf(" %s\n", ugDim("- 跳过 (无可用校验信息)")) return nil } // validateNewBinary checks the downloaded binary is valid. func validateNewBinary(binaryPath, expectedVersion string) error { info, err := os.Stat(binaryPath) if err != nil { return fmt.Errorf("文件不存在: %w", err) } if info.Size() == 0 { return fmt.Errorf("文件为空") } if err := os.Chmod(binaryPath, 0755); err != nil { return fmt.Errorf("设置执行权限失败: %w", err) } out, err := tryExecVersion(binaryPath) if err != nil { // Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid. // Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once. if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) { if repairErr := repairDarwinBinary(binaryPath); repairErr == nil { out, err = tryExecVersion(binaryPath) } } if err != nil { return fmt.Errorf("二进制无法执行: %w", err) } } if !strings.Contains(string(out), expectedVersion) { // Not fatal, version format might differ fmt.Printf("\n 注意: 版本输出中未包含 %s", expectedVersion) } return nil } func tryExecVersion(binaryPath string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return exec.CommandContext(ctx, binaryPath, "version").CombinedOutput() } // isLikelyAMFIKill returns true when err looks like macOS amfid SIGKILL'ing an // unsigned binary. Go reports this as "signal: killed". func isLikelyAMFIKill(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "signal: killed") || strings.Contains(msg, "signal: kill") } // repairDarwinBinary applies an ad-hoc codesign and clears the quarantine xattr. // Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon. func repairDarwinBinary(binaryPath string) error { // Best-effort: strip quarantine. Failure is fine (attribute often absent). _ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run() if _, err := exec.LookPath("codesign"); err != nil { return fmt.Errorf("codesign 不可用: %w", err) } out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput() if err != nil { return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out))) } return nil } // extractTarGz extracts a .tar.gz file using the system tar command. func extractTarGz(archivePath, destDir string) error { os.MkdirAll(destDir, 0755) cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("tar 解压失败: %v: %s", err, string(out)) } return nil } func progressBar(percent float64) string { width := 20 filled := int(percent / 100 * float64(width)) if filled > width { filled = width } return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) } // 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 } } 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 hashEnd < 7 { return line } 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 { homeDir, err := os.UserHomeDir() if err != nil { return path } if strings.HasPrefix(path, homeDir) { return "~" + path[len(homeDir):] } return path }