Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c93243a0 | ||
|
|
4259336e6d | ||
|
|
a18ce2e54d | ||
|
|
a0dc5d6183 | ||
|
|
5149f6808f | ||
|
|
918db33a8b | ||
|
|
95cbde9187 | ||
|
|
5e194393ff | ||
|
|
3da572a76a | ||
|
|
a116cba8ba | ||
|
|
fe5952fe14 | ||
|
|
2ab45ffd90 | ||
|
|
7c4932154c | ||
|
|
e2dbaa7c78 | ||
|
|
11a0dbc84a | ||
|
|
ee441643dd | ||
|
|
4c5affba99 | ||
|
|
c8148ef2cc | ||
|
|
b89740bad6 | ||
|
|
5c0d2b274c | ||
|
|
0489cd67c8 | ||
|
|
15d495e56e | ||
|
|
c74f1eeb26 | ||
|
|
933615003c | ||
|
|
3c75c66d4d | ||
|
|
93d6fdb17e | ||
|
|
110f887181 | ||
|
|
25118d1ec7 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.6%"><title>coverage: 52.6%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.6%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.6%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -141,3 +141,41 @@ jobs:
|
||||
|
||||
- name: Generated Drift
|
||||
run: ./scripts/policy/check-generated-drift.sh
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [test, policy, edition-tests]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
# Trigger internal GitLab CI pipeline via webhook.
|
||||
# WUKONG_TRIGGER_TOKEN is a repository secret.
|
||||
if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=${{ github.sha }}" \
|
||||
"${{ secrets.WUKONG_TRIGGER_URL }}"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
|
||||
@@ -33,7 +33,8 @@ jobs:
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
html_url: issue.html_url
|
||||
html_url: issue.html_url,
|
||||
labels: (issue.labels || []).map(label => label.name)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -28,13 +28,14 @@ jobs:
|
||||
const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`;
|
||||
const content = issue.body?.substring(0, 500) || 'No description';
|
||||
const url = issue.html_url;
|
||||
const labelsText = (issue.labels || []).map(label => label.name).join(', ') || '无标签';
|
||||
|
||||
// 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式
|
||||
const message = {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
title: 'GitHub Issue 通知',
|
||||
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
|
||||
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n🏷️ **Labels**: ${labelsText}\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,3 +27,4 @@ test/cli_compat/testdata/
|
||||
credentials*
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
GO ?= go
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -34,6 +34,9 @@ policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
package:
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
- [Why dws?](#why-dws)
|
||||
- [Installation](#installation)
|
||||
- [Upgrade](#upgrade)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Using with Agents](#using-with-agents)
|
||||
@@ -85,6 +86,41 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
</details>
|
||||
|
||||
## Upgrade
|
||||
|
||||
dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
|
||||
|
||||
```bash
|
||||
dws upgrade # interactive upgrade to latest version
|
||||
dws upgrade --check # check for new versions without installing
|
||||
dws upgrade --list # list all available versions
|
||||
dws upgrade --version v1.0.7 # upgrade to a specific version
|
||||
dws upgrade --rollback # rollback to the previous version
|
||||
dws upgrade -y # skip confirmation prompt
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--check` | Check for updates without installing |
|
||||
| `--list` | List all available versions with changelogs |
|
||||
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
|
||||
| `--rollback` | Rollback to the previous backed-up version |
|
||||
| `--force` | Force reinstall even if already on the latest version |
|
||||
| `--skip-skills` | Skip skill package update |
|
||||
| `-y` | Skip confirmation prompt |
|
||||
|
||||
</details>
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
- [为什么选择 dws?](#why-dws)
|
||||
- [安装](#安装)
|
||||
- [升级](#升级)
|
||||
- [开始使用](#开始使用)
|
||||
- [快速开始](#快速开始)
|
||||
- [在 Agent 中使用](#在-agent-中使用)
|
||||
@@ -85,6 +86,41 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
</details>
|
||||
|
||||
## 升级
|
||||
|
||||
dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
|
||||
|
||||
```bash
|
||||
dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
升级过程采用两阶段原子流程,确保一致性:
|
||||
|
||||
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
|
||||
|
||||
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--check` | 仅检查更新,不安装 |
|
||||
| `--list` | 列出所有可用版本及更新日志 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7`) |
|
||||
| `--rollback` | 回滚到上一个备份版本 |
|
||||
| `--force` | 强制重新安装,即使已是最新版本 |
|
||||
| `--skip-skills` | 跳过技能包更新 |
|
||||
| `-y` | 跳过确认提示 |
|
||||
|
||||
</details>
|
||||
|
||||
## 开始使用
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,8 +24,9 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -48,7 +49,9 @@ func buildAuthCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
if !edition.Get().HideAuthLogin {
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
@@ -206,7 +209,9 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -236,6 +241,8 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
tokenData = updatedData
|
||||
refreshed = true
|
||||
}
|
||||
} else if edition.Get().AutoPurgeToken {
|
||||
_ = authpkg.DeleteTokenData(configDir)
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
@@ -263,7 +270,9 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -341,7 +350,9 @@ func newAuthResetCommand() *cobra.Command {
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ package app
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Build-time variables injected via ldflags when available.
|
||||
@@ -28,7 +30,9 @@ func defaultConfigDir() string {
|
||||
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
|
||||
if fn := edition.Get().ConfigDir; fn != nil {
|
||||
return fn()
|
||||
}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return exeRelativeConfigDir()
|
||||
|
||||
@@ -216,10 +216,10 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandDoesNotRegisterUpgradeCommand(t *testing.T) {
|
||||
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd != nil {
|
||||
t.Fatalf("findCommand(upgrade) = %q, want nil", cmd.CommandPath())
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
|
||||
t.Fatal("upgrade command should be registered on root, but was not found")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-2
@@ -30,16 +30,25 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
if fn := edition.Get().StaticServers; fn != nil {
|
||||
injectStaticServers(fn())
|
||||
// Static servers provided by the edition hook — skip Market discovery
|
||||
// entirely. The overlay registers its own product commands via
|
||||
// RegisterExtraCommands; we only add the open-source helpers here.
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
var commands []*cobra.Command
|
||||
// Generate commands dynamically from the market discovery API.
|
||||
if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
|
||||
commands = append(commands, dynamicCmds...)
|
||||
}
|
||||
@@ -47,6 +56,26 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
// injectStaticServers converts edition.ServerInfo entries into
|
||||
// market.ServerDescriptor and feeds them into SetDynamicServers so the
|
||||
// direct-runtime endpoint resolver can find them.
|
||||
func injectStaticServers(servers []edition.ServerInfo) {
|
||||
descriptors := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
descriptors = append(descriptors, market.ServerDescriptor{
|
||||
Key: s.ID,
|
||||
DisplayName: s.Name,
|
||||
Endpoint: s.Endpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: s.ID,
|
||||
Command: s.ID,
|
||||
Prefixes: s.Prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
SetDynamicServers(descriptors)
|
||||
}
|
||||
|
||||
// loadDynamicCommands loads the server registry and generates CLI commands
|
||||
// dynamically from CLIOverlay metadata. It consults the disk cache first.
|
||||
// Within the short revalidation window it uses the cached registry directly;
|
||||
|
||||
+83
-17
@@ -29,7 +29,6 @@ import (
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
@@ -41,6 +40,8 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -278,6 +279,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newCacheCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
schemaCmd,
|
||||
genSkillsCmd,
|
||||
@@ -287,6 +289,12 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...)
|
||||
root.AddCommand(newLegacyHiddenCommands(runner)...)
|
||||
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
caller := newToolCallerAdapter(runner, flags)
|
||||
fn(root, caller)
|
||||
deduplicateCommands(root)
|
||||
}
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
configureRootHelp(root)
|
||||
// Set custom flag error handler for better UX
|
||||
@@ -465,24 +473,51 @@ func newVersionCommand() *cobra.Command {
|
||||
Example: " dws version\n dws version --format json",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read format flag")
|
||||
wantJSON := cmd.Flags().Changed("format")
|
||||
if wantJSON {
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
wantJSON = (format == "json")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"version": Version(),
|
||||
"go": "1.24+",
|
||||
|
||||
editionName := edition.Get().Name
|
||||
if editionName == "" {
|
||||
editionName = "open"
|
||||
}
|
||||
if format == "json" {
|
||||
ver := RawVersion()
|
||||
bt := BuildTime()
|
||||
gc := GitCommit()
|
||||
goVer := "1.24+"
|
||||
|
||||
arch := "MCP Dynamic Aggregation"
|
||||
|
||||
if wantJSON {
|
||||
payload := map[string]any{
|
||||
"version": ver,
|
||||
"edition": editionName,
|
||||
"architecture": arch,
|
||||
"go": goVer,
|
||||
}
|
||||
if bt != "unknown" {
|
||||
payload["build"] = bt
|
||||
}
|
||||
if gc != "unknown" {
|
||||
payload["commit"] = gc
|
||||
}
|
||||
return output.WriteJSON(cmd.OutOrStdout(), payload)
|
||||
}
|
||||
_, err = fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"版本: %s\nGo: %s\n",
|
||||
Version(),
|
||||
"1.24+",
|
||||
)
|
||||
return err
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Version:", ver)
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Edition:", editionName)
|
||||
if bt != "unknown" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Build:", bt)
|
||||
}
|
||||
if gc != "unknown" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Commit:", gc)
|
||||
}
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Architecture:", arch)
|
||||
fmt.Fprintf(w, "%-16s%s\n", "Go:", goVer)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -579,17 +614,30 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
|
||||
}
|
||||
|
||||
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
|
||||
// unless they correspond to a product discovered via dynamic server discovery.
|
||||
// unless they correspond to a product discovered via dynamic server discovery
|
||||
// or listed in the edition's VisibleProducts hook.
|
||||
// Public utility commands (auth, cache, completion, version) are always kept
|
||||
// visible; explicitly hidden commands stay hidden.
|
||||
func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
allowedProducts := DirectRuntimeProductIDs()
|
||||
var allowedProducts map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowedProducts = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowedProducts[p] = true
|
||||
}
|
||||
} else {
|
||||
allowedProducts = DirectRuntimeProductIDs()
|
||||
}
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"cache": true,
|
||||
"completion": true,
|
||||
"version": true,
|
||||
"help": true,
|
||||
"recovery": true,
|
||||
"schema": true,
|
||||
"mcp": true,
|
||||
}
|
||||
for _, cmd := range root.Commands() {
|
||||
name := cmd.Name()
|
||||
@@ -606,6 +654,24 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// deduplicateCommands removes duplicate top-level commands, keeping the last
|
||||
// registered one. This ensures overlay commands take precedence over
|
||||
// open-source defaults when both register the same product name.
|
||||
func deduplicateCommands(root *cobra.Command) {
|
||||
seen := make(map[string]*cobra.Command)
|
||||
var dups []*cobra.Command
|
||||
for _, cmd := range root.Commands() {
|
||||
name := cmd.Name()
|
||||
if prev, ok := seen[name]; ok {
|
||||
dups = append(dups, prev)
|
||||
}
|
||||
seen[name] = cmd
|
||||
}
|
||||
for _, dup := range dups {
|
||||
root.RemoveCommand(dup)
|
||||
}
|
||||
}
|
||||
|
||||
func cacheStoreFromEnv() *cache.Store {
|
||||
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
|
||||
return cache.NewStore(cacheDir)
|
||||
|
||||
@@ -172,8 +172,8 @@ func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(version) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "\"version\"") {
|
||||
t.Fatalf("version output missing version key:\n%s", out.String())
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +213,8 @@ func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing
|
||||
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
|
||||
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
|
||||
}
|
||||
if !strings.Contains(out.String(), "\"version\"") {
|
||||
t.Fatalf("version output missing version key:\n%s", out.String())
|
||||
if !strings.Contains(out.String(), "Version:") {
|
||||
t.Fatalf("version output missing Version line:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -53,7 +54,16 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
allowed := DirectRuntimeProductIDs()
|
||||
var allowed map[string]bool
|
||||
if fn := edition.Get().VisibleProducts; fn != nil {
|
||||
products := fn()
|
||||
allowed = make(map[string]bool, len(products))
|
||||
for _, p := range products {
|
||||
allowed[p] = true
|
||||
}
|
||||
} else {
|
||||
allowed = DirectRuntimeProductIDs()
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -191,6 +192,11 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", callDuration)
|
||||
}
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
_ = fn(defaultConfigDir(), err)
|
||||
}
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
@@ -335,6 +341,14 @@ func runtimeFlagEnabled(raw string, defaultValue bool) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isAuthError(err error) bool {
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Category == apperrors.CategoryAuth
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func productEndpointOverride(productID string) (string, bool) {
|
||||
key := "DINGTALK_" + strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(productID), "-", "_")) + "_MCP_URL"
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
@@ -365,6 +379,9 @@ func resolveIdentityHeaders() map[string]string {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// toolCallerAdapter bridges executor.Runner to the public edition.ToolCaller
|
||||
// interface so that private overlays can invoke MCP tools without importing
|
||||
// internal packages.
|
||||
type toolCallerAdapter struct {
|
||||
runner executor.Runner
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller {
|
||||
return &toolCallerAdapter{runner: runner, flags: flags}
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
|
||||
result, err := a.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertResult(result), nil
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Format() string {
|
||||
if a.flags != nil {
|
||||
return a.flags.Format
|
||||
}
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) DryRun() bool {
|
||||
return a.flags != nil && a.flags.DryRun
|
||||
}
|
||||
|
||||
func convertResult(r executor.Result) *edition.ToolResult {
|
||||
resp := r.Response
|
||||
if resp == nil {
|
||||
return &edition.ToolResult{}
|
||||
}
|
||||
|
||||
// The runtime runner stores MCP response content under "content".
|
||||
contentRaw, ok := resp["content"]
|
||||
if !ok {
|
||||
// Dry-run or echo mode: serialize the whole response as text.
|
||||
data, _ := json.Marshal(resp)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
}
|
||||
|
||||
// Content may be a []any of {type, text} blocks from the MCP response,
|
||||
// or a single map for mock mode.
|
||||
switch v := contentRaw.(type) {
|
||||
case []any:
|
||||
blocks := make([]edition.ContentBlock, 0, len(v))
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
blocks = append(blocks, edition.ContentBlock{
|
||||
Type: strVal(m, "type"),
|
||||
Text: strVal(m, "text"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return &edition.ToolResult{Content: blocks}
|
||||
case map[string]any:
|
||||
data, _ := json.Marshal(v)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
default:
|
||||
data, _ := json.Marshal(contentRaw)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strVal(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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
|
||||
flagList bool
|
||||
flagVersion string
|
||||
flagRollback bool
|
||||
flagForce bool
|
||||
flagSkipSkills 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 --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(cmd, format)
|
||||
}
|
||||
if flagRollback {
|
||||
return runUpgradeRollback(yes)
|
||||
}
|
||||
if flagCheck {
|
||||
return runUpgradeCheck(cmd, format)
|
||||
}
|
||||
return runUpgrade(cmd.Context(), upgradeOptions{
|
||||
targetVersion: flagVersion,
|
||||
force: flagForce,
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出所有可用版本")
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
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.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)))
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.5 安装指定版本"))
|
||||
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.
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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("✓"))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Try running the binary
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,21 @@ package app
|
||||
|
||||
var version = "dev"
|
||||
|
||||
// SetVersion overrides the version, build time and git commit strings.
|
||||
// Called by pkg/cli.SetVersion for overlay modules that inject their own
|
||||
// version info via ldflags.
|
||||
func SetVersion(v, bt, gc string) {
|
||||
if v != "" {
|
||||
version = v
|
||||
}
|
||||
if bt != "" {
|
||||
buildTime = bt
|
||||
}
|
||||
if gc != "" {
|
||||
gitCommit = gc
|
||||
}
|
||||
}
|
||||
|
||||
// Version returns the current CLI version string, including build metadata
|
||||
// when injected via ldflags (buildTime, gitCommit).
|
||||
func Version() string {
|
||||
@@ -23,3 +38,12 @@ func Version() string {
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// RawVersion returns the bare version string without build metadata.
|
||||
func RawVersion() string { return version }
|
||||
|
||||
// BuildTime returns the build timestamp injected via ldflags.
|
||||
func BuildTime() string { return buildTime }
|
||||
|
||||
// GitCommit returns the git commit hash injected via ldflags.
|
||||
func GitCommit() string { return gitCommit }
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setupMCPConfigDir creates a temp config dir with mcp_url pointing to the
|
||||
// given test server and sets DWS_CONFIG_DIR via t.Setenv.
|
||||
// NOTE: tests calling this must NOT use t.Parallel().
|
||||
func setupMCPConfigDir(t *testing.T, srvURL string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "mcp_url"), []byte(srvURL), 0o600)
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
// resetClientIDFromMCP clears the MCP-sourced flag (test helper).
|
||||
func resetClientIDFromMCP() {
|
||||
clientMu.Lock()
|
||||
defer clientMu.Unlock()
|
||||
clientIDFromMCP = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. CheckCLIAuthEnabled: interface error → fail-closed with retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_ServerError_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error from CheckCLIAuthEnabled when server returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ Server 500 → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_ConnectionRefused_FailClosed(t *testing.T) {
|
||||
configDir := setupMCPConfigDir(t, "http://127.0.0.1:1")
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
httpClient: &http.Client{Timeout: 2 * time.Second},
|
||||
}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when connection is refused, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ Connection refused → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_MalformedJSON_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{this is not valid json}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON, got nil")
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ Malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Timeout_FailClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(5 * time.Second)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
httpClient: &http.Client{Timeout: 200 * time.Millisecond},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := p.CheckCLIAuthEnabled(ctx, "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error on request timeout, got nil")
|
||||
}
|
||||
t.Logf("✅ Timeout → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. CheckCLIAuthEnabled: transient error then recovery → succeeds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !status.Success || !status.Result.CLIAuthEnabled {
|
||||
t.Fatalf("expected CLIAuthEnabled=true, got %+v", status)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c)
|
||||
}
|
||||
t.Logf("✅ Transient error then success: attempts=%d, enabled=%v", calls.Load(), status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. CheckCLIAuthEnabled: normal responses (pass-through)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "good-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "fake-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=false")
|
||||
}
|
||||
t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. OAuth callback: simulates the fail-closed logic at the /callback level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOAuthCallback_CLIAuthError_ShowsNotEnabledPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error = fmt.Errorf("simulated network error")
|
||||
var authStatus *CLIAuthStatus
|
||||
_ = authStatus
|
||||
|
||||
// This is the exact expression used in oauth_provider.go callback:
|
||||
// cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil // false
|
||||
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when statusErr != nil")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=%v → cliAuthEnabled=%v → shows notEnabledHTML (fail-closed)", statusErr, cliAuthEnabled)
|
||||
}
|
||||
|
||||
func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
if !cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be true when API returns enabled")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=nil, enabled=true → cliAuthEnabled=%v → shows successHTML", cliAuthEnabled)
|
||||
}
|
||||
|
||||
func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var statusErr error
|
||||
authStatus := &CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
}
|
||||
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
if cliAuthEnabled {
|
||||
t.Fatal("cliAuthEnabled should be false when server says disabled")
|
||||
}
|
||||
t.Logf("✅ OAuth callback: statusErr=nil, enabled=false → cliAuthEnabled=%v → shows notEnabledHTML", cliAuthEnabled)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Device Flow: loginOnce with broken cliAuthEnabled endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthError_FailClosed(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
|
||||
default:
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected loginOnce to fail when CLI auth check fails, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "检查 CLI 授权状态失败") && !strings.Contains(err.Error(), "Failed to check CLI auth status") {
|
||||
t.Fatalf("unexpected error message: %s", err)
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth check error → login blocked: %s", err)
|
||||
}
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: false},
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, SuperAdminPath):
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "admin1", Name: "张三"}},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected loginOnce to fail when CLI auth is disabled, got nil")
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth disabled by server → login blocked: %s", err)
|
||||
}
|
||||
|
||||
func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) {
|
||||
SetClientIDFromMCP("test-client-id")
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, DeviceCodePath):
|
||||
writeServiceResult(w, true, DeviceAuthResponse{
|
||||
DeviceCode: "dc-test",
|
||||
UserCode: "TEST-CODE",
|
||||
VerificationURI: "https://example.com/verify",
|
||||
ExpiresIn: 60,
|
||||
Interval: 1,
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, DeviceTokenPath):
|
||||
writeServiceResult(w, true, DeviceTokenResponse{
|
||||
AuthCode: "test-auth-code",
|
||||
}, "", "")
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, MCPOAuthTokenPath):
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"accessToken": "test-access-token",
|
||||
"refreshToken": "test-refresh-token",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "corp123",
|
||||
})
|
||||
|
||||
case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath):
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: struct {
|
||||
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
||||
}{CLIAuthEnabled: true},
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
provider := &DeviceFlowProvider{
|
||||
configDir: configDir,
|
||||
clientID: "test-client-id",
|
||||
scope: DefaultScopes,
|
||||
baseURL: srv.URL,
|
||||
logger: newDeviceFlowTestLogger(),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
token, err := provider.loginOnce(context.Background(), 1)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected loginOnce to succeed, got error: %v", err)
|
||||
}
|
||||
if token.AccessToken != "test-access-token" {
|
||||
t.Fatalf("unexpected token: %s", token.AccessToken)
|
||||
}
|
||||
t.Logf("✅ Device Flow: CLI auth enabled → login succeeded, token=%s", token.AccessToken)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. FetchClientIDFromMCP: /cli/clientId error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFetchClientIDFromMCP_ServerError_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/clientId returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId 500 → fail-closed with retry: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_ConnectionRefused_FailClosed(t *testing.T) {
|
||||
setupMCPConfigDir(t, "http://127.0.0.1:1")
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when connection is refused, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId connection refused → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_MalformedJSON_FailClosed(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `not json at all`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON, got nil")
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId malformed JSON → fail-closed: error=%q, attempts=%d", err, calls.Load())
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_BusinessError_FailClosed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: false,
|
||||
ErrorCode: "forbidden",
|
||||
ErrorMsg: "access denied",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when server returns success=false, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "access denied") {
|
||||
t.Fatalf("error should contain server error message, got: %s", err)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId business error → fail-closed: error=%q", err)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: true,
|
||||
Result: "recovered-client-id",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
id, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if id != "recovered-client-id" {
|
||||
t.Fatalf("expected client ID 'recovered-client-id', got %q", id)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 failures + 1 success), got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId transient then success: attempts=%d, id=%s", calls.Load(), id)
|
||||
}
|
||||
|
||||
func TestFetchClientIDFromMCP_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != ClientIDPath {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClientIDResponse{
|
||||
Success: true,
|
||||
Result: "my-client-id-123",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
id, err := FetchClientIDFromMCP(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != "my-client-id-123" {
|
||||
t.Fatalf("expected 'my-client-id-123', got %q", id)
|
||||
}
|
||||
t.Logf("✅ /cli/clientId normal success: id=%s", id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. GetSuperAdmins: /cli/superAdmin error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetSuperAdmins_ServerError_RetriesAndFails(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := GetSuperAdmins(context.Background(), "fake-token")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/superAdmin returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin 500 → retried 3 times: error=%q", err)
|
||||
}
|
||||
|
||||
func TestGetSuperAdmins_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 2 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "a1", Name: "张三"}, {StaffID: "a2", Name: "李四"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := GetSuperAdmins(context.Background(), "fake-token")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failures, got error: %v", err)
|
||||
}
|
||||
if !result.Success || len(result.Result) != 2 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin transient then success: attempts=%d, admins=%v", calls.Load(), result.Result)
|
||||
}
|
||||
|
||||
func TestGetSuperAdmins_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SuperAdminResponse{
|
||||
Success: true,
|
||||
Result: []SuperAdmin{{StaffID: "admin1", Name: "王五"}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := GetSuperAdmins(context.Background(), "good-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result.Result) != 1 || result.Result[0].Name != "王五" {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
t.Logf("✅ /cli/superAdmin normal success: admins=%v", result.Result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. SendCliAuthApply: /cli/sendCliAuthApply error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSendCliAuthApply_ServerError_RetriesAndFails(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
_, err := SendCliAuthApply(context.Background(), "fake-token", "admin1")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when /cli/sendCliAuthApply returns 500, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed after 3 attempts") {
|
||||
t.Fatalf("error should mention retry exhaustion, got: %s", err)
|
||||
}
|
||||
if c := calls.Load(); c != 3 {
|
||||
t.Fatalf("expected 3 retry attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply 500 → retried 3 times: error=%q", err)
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_TransientThenSuccess(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n <= 1 {
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "fake-token", "admin1")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected success after transient failure, got error: %v", err)
|
||||
}
|
||||
if !result.Success || !result.Result {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if c := calls.Load(); c != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", c)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply transient then success: attempts=%d", calls.Load())
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-user-access-token") != "good-token" {
|
||||
t.Errorf("missing access token header")
|
||||
}
|
||||
if !strings.Contains(r.URL.RawQuery, "adminStaffId=admin123") {
|
||||
t.Errorf("missing or wrong adminStaffId param: %s", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "good-token", "admin123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !result.Success || !result.Result {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply normal success: result=%+v", result)
|
||||
}
|
||||
|
||||
func TestSendCliAuthApply_BusinessError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(SendApplyResponse{
|
||||
Success: false,
|
||||
ErrorCode: "invalid_admin",
|
||||
ErrorMsg: "admin not found",
|
||||
Result: false,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
setupMCPConfigDir(t, srv.URL)
|
||||
|
||||
result, err := SendCliAuthApply(context.Background(), "fake-token", "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected success=false for business error")
|
||||
}
|
||||
t.Logf("✅ /cli/sendCliAuthApply business error: errorCode=%s, errorMsg=%s", result.ErrorCode, result.ErrorMsg)
|
||||
}
|
||||
@@ -26,8 +26,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
@@ -167,14 +167,15 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
|
||||
}
|
||||
|
||||
// Check if CLI auth is enabled for this organization
|
||||
// Check if CLI auth is enabled for this organization (fail-closed: block on error)
|
||||
dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
|
||||
authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
|
||||
if authErr != nil {
|
||||
if p.logger != nil {
|
||||
p.logger.Warn("failed to check CLI auth status", "error", authErr)
|
||||
}
|
||||
// Continue anyway - fail open for better UX
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 无法检查 CLI 数据访问权限状态")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr)
|
||||
} else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
|
||||
// CLI auth is disabled - show detailed error with admin info
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -25,7 +25,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const identityFile = "identity.json"
|
||||
@@ -82,8 +83,11 @@ func (id *Identity) Headers() map[string]string {
|
||||
if id.Source != "" {
|
||||
h["x-dws-source"] = id.Source
|
||||
}
|
||||
// Constant headers for MCP gateway tracking
|
||||
h["x-dingtalk-scenario-code"] = "com.dingtalk.cli"
|
||||
scenarioCode := "com.dingtalk.cli"
|
||||
if sc := edition.Get().ScenarioCode; sc != "" {
|
||||
scenarioCode = sc
|
||||
}
|
||||
h["x-dingtalk-scenario-code"] = scenarioCode
|
||||
h["x-dingtalk-source"] = "github"
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
+708
-184
@@ -23,7 +23,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
|
||||
@@ -296,190 +296,628 @@ func buildAuthURL(clientID, redirectURI string) string {
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
const successHTML = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>钉钉 CLI</title>
|
||||
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f5f5f5}
|
||||
.card{background:#fff;border-radius:12px;padding:40px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.08)}
|
||||
h1{color:#1677ff;margin:0 0 8px}p{color:#666;margin:0}</style></head>
|
||||
<body><div class="card"><h1>✅ 授权成功</h1><p>请返回终端继续操作。此页面可以关闭。</p></div></body></html>`
|
||||
|
||||
const notEnabledHTML = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>钉钉 CLI</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff;padding:20px}
|
||||
.container{text-align:center;max-width:400px;width:100%}
|
||||
.lock-icon{width:80px;height:100px;margin:0 auto 32px;position:relative}
|
||||
.lock-body{width:60px;height:50px;background:linear-gradient(135deg,#1890ff 0%,#69c0ff 100%);
|
||||
border-radius:8px;position:absolute;bottom:0;left:50%;transform:translateX(-50%);
|
||||
box-shadow:0 8px 24px rgba(24,144,255,0.3)}
|
||||
.lock-body::before{content:"";position:absolute;width:12px;height:12px;background:#fff;
|
||||
border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%)}
|
||||
.lock-body::after{content:"";position:absolute;width:4px;height:12px;background:#fff;
|
||||
top:50%;left:50%;transform:translateX(-50%);margin-top:4px;border-radius:2px}
|
||||
.lock-shackle{width:36px;height:36px;border:6px solid #1890ff;border-bottom:none;
|
||||
border-radius:20px 20px 0 0;position:absolute;top:8px;left:50%;transform:translateX(-50%)}
|
||||
h1{font-size:22px;font-weight:600;color:#181c1f;margin-bottom:16px;line-height:1.4}
|
||||
p{font-size:15px;color:#5f6368;line-height:1.6;margin-bottom:24px}
|
||||
.form-group{text-align:left;margin-bottom:24px}
|
||||
.form-label{font-size:14px;color:#181c1f;margin-bottom:8px;display:block}
|
||||
.select-wrapper{position:relative}
|
||||
select{width:100%;padding:12px 40px 12px 16px;font-size:15px;border:1px solid #d9d9d9;
|
||||
border-radius:8px;background:#fff;appearance:none;cursor:pointer;color:#181c1f}
|
||||
select:focus{outline:none;border-color:#1890ff;box-shadow:0 0 0 2px rgba(24,144,255,0.1)}
|
||||
.select-arrow{position:absolute;right:16px;top:50%;transform:translateY(-50%);pointer-events:none;
|
||||
border:5px solid transparent;border-top:6px solid #5f6368}
|
||||
.btn{display:block;width:100%;padding:14px 24px;background:linear-gradient(135deg,#1890ff 0%,#40a9ff 100%);
|
||||
color:#fff;font-size:16px;font-weight:500;border-radius:24px;border:none;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(24,144,255,0.3);transition:all 0.2s}
|
||||
.btn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(24,144,255,0.4)}
|
||||
.btn:disabled{background:#d9d9d9;box-shadow:none;cursor:not-allowed;transform:none}
|
||||
.link{color:#1890ff;font-size:14px;text-decoration:none;margin-top:16px;display:inline-block}
|
||||
.link:hover{text-decoration:underline}
|
||||
.success-msg{display:none;width:100%;min-height:36px;gap:12px;padding:8px 12px;margin-top:20px;
|
||||
background:linear-gradient(0deg,rgba(0,102,255,0.12) 0%,rgba(0,102,255,0.12) 100%),linear-gradient(0deg,#FFFFFF 0%,#FFFFFF 100%);
|
||||
border-radius:12px;align-items:center;justify-content:flex-start}
|
||||
.success-msg-icon{width:16px;height:16px;flex-shrink:0}
|
||||
.success-msg-text{flex:1;color:#181C1F;font-size:14px;line-height:22px;text-align:left}
|
||||
.error-msg{color:#ff4d4f;font-size:14px;margin-top:8px;display:none}
|
||||
.loading{display:inline-block;width:16px;height:16px;border:2px solid #fff;border-top-color:transparent;
|
||||
border-radius:50%;animation:spin 0.8s linear infinite;margin-right:8px;vertical-align:middle}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style></head>
|
||||
<body><div class="container">
|
||||
<div class="lock-icon"><div class="lock-shackle"></div><div class="lock-body"></div></div>
|
||||
<h1>该组织尚未开启 CLI 数据访问权限</h1>
|
||||
<p>你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">选择一位主管理员发送开通申请</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="adminSelect"><option value="">加载中...</option></select>
|
||||
<div class="select-arrow"></div>
|
||||
</div>
|
||||
<div id="errorMsg" class="error-msg"></div>
|
||||
</div>
|
||||
<button id="applyBtn" class="btn" disabled>立即申请</button>
|
||||
<a id="backLink" class="link" href="#">返回选择其他组织</a>
|
||||
<div id="successMsg" class="success-msg">
|
||||
<svg class="success-msg-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1.33333C4.32 1.33333 1.33333 4.32 1.33333 8C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8C14.6667 4.32 11.68 1.33333 8 1.33333ZM8 13.3333C5.05333 13.3333 2.66667 10.9467 2.66667 8C2.66667 5.05333 5.05333 2.66667 8 2.66667C10.9467 2.66667 13.3333 5.05333 13.3333 8C13.3333 10.9467 10.9467 13.3333 8 13.3333ZM7.33333 9.33333H8.66667V10.6667H7.33333V9.33333ZM7.33333 5.33333H8.66667V8H7.33333V5.33333Z" fill="#0066FF"/>
|
||||
</svg>
|
||||
<span class="success-msg-text">已向管理员发送权限申请,正在等待审核<br/>审核通过后,请返回终端继续操作</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const select=document.getElementById('adminSelect'),btn=document.getElementById('applyBtn'),
|
||||
successMsg=document.getElementById('successMsg'),errorMsg=document.getElementById('errorMsg'),
|
||||
backLink=document.getElementById('backLink');
|
||||
let admins=[],clientId='',applySent=false,selectedAdminId='',pollTimer=null;
|
||||
|
||||
// Load status first, then load admins
|
||||
async function init(){
|
||||
try{
|
||||
const statusRes=await fetch('/api/status');
|
||||
const status=await statusRes.json();
|
||||
clientId=status.clientId||'';
|
||||
applySent=status.applySent||false;
|
||||
selectedAdminId=status.selectedAdminId||'';
|
||||
// Set back link URL
|
||||
if(clientId){
|
||||
const port=location.port;
|
||||
const redirectUri=encodeURIComponent('http://127.0.0.1:'+port+'/callback');
|
||||
backLink.href='https://login.dingtalk.com/oauth2/auth?client_id='+clientId+
|
||||
'&prompt=consent&redirect_uri='+redirectUri+'&response_type=code&scope=openid+corpid';
|
||||
}
|
||||
// Restore state if apply was sent
|
||||
if(applySent){
|
||||
setAppliedState();
|
||||
}
|
||||
}catch(e){console.error('Failed to load status',e);}
|
||||
loadAdmins();
|
||||
}
|
||||
|
||||
function setAppliedState(){
|
||||
btn.style.display='none';
|
||||
select.disabled=true;
|
||||
successMsg.style.display='flex';
|
||||
backLink.style.pointerEvents='none';
|
||||
backLink.style.color='#999';
|
||||
backLink.onclick=function(e){e.preventDefault();return false;};
|
||||
// Start polling for auth status
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function startPolling(){
|
||||
if(pollTimer)return;
|
||||
pollTimer=setInterval(checkAuthStatus,5000);
|
||||
// Also check immediately
|
||||
checkAuthStatus();
|
||||
}
|
||||
|
||||
async function checkAuthStatus(){
|
||||
try{
|
||||
const res=await fetch('/api/cliAuthEnabled');
|
||||
const data=await res.json();
|
||||
if(data.success&&data.result&&data.result.cliAuthEnabled){
|
||||
// Auth enabled! Redirect to success page
|
||||
stopPolling();
|
||||
location.href='/success';
|
||||
}
|
||||
}catch(e){console.error('Poll error',e);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(){
|
||||
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
|
||||
}
|
||||
|
||||
async function loadAdmins(){
|
||||
try{
|
||||
const res=await fetch('/api/superAdmin');
|
||||
const data=await res.json();
|
||||
if(data.success&&data.result&&data.result.length>0){
|
||||
admins=data.result;
|
||||
select.innerHTML='<option value="">请选择</option>'+
|
||||
admins.map(a=>'<option value="'+a.staffId+'">'+a.name+'</option>').join('');
|
||||
// Restore selected admin if available
|
||||
if(selectedAdminId){
|
||||
select.value=selectedAdminId;
|
||||
const successHTML = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
btn.disabled=!select.value;
|
||||
}else{
|
||||
select.innerHTML='<option value="">暂无可选管理员</option>';
|
||||
showError(data.errorMsg||'获取管理员列表失败');
|
||||
}
|
||||
}catch(e){
|
||||
select.innerHTML='<option value="">加载失败</option>';
|
||||
showError('网络错误,请重试');
|
||||
}
|
||||
}
|
||||
function showError(msg){errorMsg.textContent=msg;errorMsg.style.display='block';}
|
||||
function hideError(){errorMsg.style.display='none';}
|
||||
select.onchange=function(){btn.disabled=!this.value;hideError();};
|
||||
btn.onclick=async function(){
|
||||
if(!select.value)return;
|
||||
btn.disabled=true;
|
||||
btn.innerHTML='<span class="loading"></span>申请中...';
|
||||
hideError();
|
||||
try{
|
||||
const res=await fetch('/api/sendApply?adminStaffId='+encodeURIComponent(select.value));
|
||||
const data=await res.json();
|
||||
if(data.success&&data.result){
|
||||
setAppliedState();
|
||||
}else{
|
||||
showError(data.errorMsg||'申请失败,请重试');
|
||||
btn.disabled=false;
|
||||
btn.textContent='立即申请';
|
||||
}
|
||||
}catch(e){
|
||||
showError('网络错误,请重试');
|
||||
btn.disabled=false;
|
||||
btn.textContent='立即申请';
|
||||
}
|
||||
};
|
||||
init();
|
||||
</script></body></html>`
|
||||
.card {
|
||||
height: 600px;
|
||||
width: 480px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>授权成功</h1>
|
||||
<p>请返回终端继续操作。此页面可以关闭。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const notEnabledHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-label {
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 14px;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
margin-top: 38px;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.custom-select-trigger {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(126, 134, 142, 0.24);
|
||||
cursor: pointer;
|
||||
color: #181c1f;
|
||||
text-align: left;
|
||||
}
|
||||
.custom-select-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: rgba(24, 28, 31, 0.48);
|
||||
}
|
||||
.custom-select.has-value .custom-select-text {
|
||||
color: #181c1f;
|
||||
}
|
||||
.custom-select-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
background-image: url("https://img.alicdn.com/imgextra/i1/O1CN01MzGSB21oZ3iyQ8H5e_!!6000000005238-55-tps-16-16.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px 16px;
|
||||
background-position: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.custom-select-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
}
|
||||
.custom-select.open .custom-select-menu {
|
||||
display: block;
|
||||
}
|
||||
.custom-select-option {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: #181c1f;
|
||||
cursor: pointer;
|
||||
}
|
||||
.custom-select-option:hover {
|
||||
background: rgba(126, 134, 142, 0.16);
|
||||
}
|
||||
.custom-select-option.is-active {
|
||||
background: #e8eaee;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 1000px;
|
||||
background: #0066ff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #0b6cff;
|
||||
}
|
||||
.btn:disabled {
|
||||
background: #d9d9d9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.link {
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
margin-top: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
.success-msg {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-top: 50px;
|
||||
margin-bottom: 16px;
|
||||
background: #eaf1ff;
|
||||
border-radius: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.success-msg-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.success-msg-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: #181c1f;
|
||||
}
|
||||
.error-msg {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
}
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #fff;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>该组织尚未开启 CLI 数据访问权限</h1>
|
||||
<p>
|
||||
你所选择的组织管理员尚未开启<br />「允许成员通过 CLI
|
||||
访问其个人数据」的权限。
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">选择一位主管理员发送开通申请</label>
|
||||
<div class="select-wrapper">
|
||||
<div class="custom-select" id="adminSelect">
|
||||
<button
|
||||
type="button"
|
||||
class="custom-select-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="custom-select-text">加载中...</span>
|
||||
<span class="custom-select-arrow"></span>
|
||||
</button>
|
||||
<ul class="custom-select-menu" role="listbox" id="adminMenu"></ul>
|
||||
<input type="hidden" name="adminStaffId" value="" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorMsg" class="error-msg"></div>
|
||||
</div>
|
||||
|
||||
<div id="successMsg" class="success-msg">
|
||||
<svg
|
||||
class="success-msg-icon"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1.33333C4.32 1.33333 1.33333 4.32 1.33333 8C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8C14.6667 4.32 11.68 1.33333 8 1.33333ZM8 13.3333C5.05333 13.3333 2.66667 10.9467 2.66667 8C2.66667 5.05333 5.05333 2.66667 8 2.66667C10.9467 2.66667 13.3333 5.05333 13.3333 8C13.3333 10.9467 10.9467 13.3333 8 13.3333ZM7.33333 9.33333H8.66667V10.6667H7.33333V9.33333ZM7.33333 5.33333H8.66667V8H7.33333V5.33333Z"
|
||||
fill="#0066FF"
|
||||
/>
|
||||
</svg>
|
||||
<span class="success-msg-text"
|
||||
>已向管理员发送权限申请,正在等待审核<br />审核通过后,请返回终端继续操作</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<button id="applyBtn" class="btn" disabled>立即申请</button>
|
||||
<a id="backLink" class="link" href="#">返回选择其他组织</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const adminSelect = document.getElementById("adminSelect");
|
||||
const trigger = adminSelect.querySelector(".custom-select-trigger");
|
||||
const text = adminSelect.querySelector(".custom-select-text");
|
||||
const menu = document.getElementById("adminMenu");
|
||||
const hiddenInput = adminSelect.querySelector('input[name="adminStaffId"]');
|
||||
const btn = document.getElementById("applyBtn");
|
||||
const successMsg = document.getElementById("successMsg");
|
||||
const errorMsg = document.getElementById("errorMsg");
|
||||
const backLink = document.getElementById("backLink");
|
||||
|
||||
let admins = [];
|
||||
let clientId = "";
|
||||
let applySent = false;
|
||||
let selectedAdminId = "";
|
||||
let pollTimer = null;
|
||||
|
||||
function closeMenu() {
|
||||
adminSelect.classList.remove("open");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
adminSelect.classList.add("open");
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorMsg.textContent = msg;
|
||||
errorMsg.style.display = "block";
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
errorMsg.style.display = "none";
|
||||
}
|
||||
|
||||
function setSelected(staffId, name) {
|
||||
hiddenInput.value = staffId || "";
|
||||
text.textContent = name || "请选择";
|
||||
if (staffId) {
|
||||
adminSelect.classList.add("has-value");
|
||||
} else {
|
||||
adminSelect.classList.remove("has-value");
|
||||
}
|
||||
btn.disabled = applySent || !staffId;
|
||||
}
|
||||
|
||||
function renderAdminOptions(list) {
|
||||
menu.innerHTML = "";
|
||||
list.forEach(function (admin) {
|
||||
const li = document.createElement("li");
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.className = "custom-select-option";
|
||||
option.setAttribute("data-value", admin.staffId);
|
||||
option.textContent = admin.name;
|
||||
|
||||
if (selectedAdminId && admin.staffId === selectedAdminId) {
|
||||
option.classList.add("is-active");
|
||||
}
|
||||
|
||||
option.addEventListener("click", function () {
|
||||
selectedAdminId = admin.staffId;
|
||||
const all = menu.querySelectorAll(".custom-select-option");
|
||||
all.forEach(function (item) {
|
||||
item.classList.remove("is-active");
|
||||
});
|
||||
option.classList.add("is-active");
|
||||
setSelected(admin.staffId, admin.name);
|
||||
closeMenu();
|
||||
hideError();
|
||||
});
|
||||
|
||||
li.appendChild(option);
|
||||
menu.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function setAppliedState() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "立即申请";
|
||||
trigger.disabled = true;
|
||||
adminSelect.classList.remove("open");
|
||||
successMsg.style.display = "flex";
|
||||
backLink.style.pointerEvents = "none";
|
||||
backLink.style.color = "#999";
|
||||
backLink.onclick = function (e) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
};
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(checkAuthStatus, 5000);
|
||||
checkAuthStatus();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/cliAuthEnabled");
|
||||
const data = await res.json();
|
||||
if (data.success && data.result && data.result.cliAuthEnabled) {
|
||||
stopPolling();
|
||||
location.href = "/success";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Poll error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdmins() {
|
||||
try {
|
||||
const res = await fetch("/api/superAdmin");
|
||||
const data = await res.json();
|
||||
if (data.success && data.result && data.result.length > 0) {
|
||||
admins = data.result;
|
||||
renderAdminOptions(admins);
|
||||
|
||||
if (selectedAdminId) {
|
||||
const selected = admins.find(function (a) {
|
||||
return a.staffId === selectedAdminId;
|
||||
});
|
||||
if (selected) {
|
||||
setSelected(selected.staffId, selected.name);
|
||||
} else {
|
||||
setSelected("", "请选择");
|
||||
}
|
||||
} else {
|
||||
setSelected("", "请选择");
|
||||
}
|
||||
} else {
|
||||
setSelected("", "暂无可选管理员");
|
||||
trigger.disabled = true;
|
||||
showError((data && data.errorMsg) || "获取管理员列表失败");
|
||||
}
|
||||
} catch (e) {
|
||||
setSelected("", "加载失败");
|
||||
trigger.disabled = true;
|
||||
showError("网络错误,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const statusRes = await fetch("/api/status");
|
||||
const status = await statusRes.json();
|
||||
clientId = status.clientId || "";
|
||||
applySent = status.applySent || false;
|
||||
selectedAdminId = status.selectedAdminId || "";
|
||||
|
||||
if (clientId) {
|
||||
const port = location.port;
|
||||
const redirectUri = encodeURIComponent(
|
||||
"http://127.0.0.1:" + port + "/callback"
|
||||
);
|
||||
backLink.href =
|
||||
"https://login.dingtalk.com/oauth2/auth?client_id=" +
|
||||
clientId +
|
||||
"&prompt=consent&redirect_uri=" +
|
||||
redirectUri +
|
||||
"&response_type=code&scope=openid+corpid";
|
||||
}
|
||||
|
||||
if (applySent) {
|
||||
setAppliedState();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load status", e);
|
||||
}
|
||||
|
||||
await loadAdmins();
|
||||
}
|
||||
|
||||
trigger.addEventListener("click", function () {
|
||||
if (trigger.disabled) return;
|
||||
if (adminSelect.classList.contains("open")) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
if (!adminSelect.contains(event.target)) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
btn.onclick = async function () {
|
||||
const value = hiddenInput.value;
|
||||
if (!value) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading"></span>申请中...';
|
||||
hideError();
|
||||
try {
|
||||
const res = await fetch(
|
||||
"/api/sendApply?adminStaffId=" + encodeURIComponent(value)
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.success && data.result) {
|
||||
setAppliedState();
|
||||
} else {
|
||||
showError(data.errorMsg || "申请失败,请重试");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "立即申请";
|
||||
}
|
||||
} catch (e) {
|
||||
showError("网络错误,请重试");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "立即申请";
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
|
||||
type CLIAuthStatus struct {
|
||||
@@ -513,8 +951,34 @@ type SendApplyResponse struct {
|
||||
Result bool `json:"result"`
|
||||
}
|
||||
|
||||
// mcpRequestMaxRetries is the maximum number of attempts for MCP API calls
|
||||
// (e.g. /cli/cliAuthEnabled, /cli/clientId, /cli/superAdmin, /cli/sendCliAuthApply)
|
||||
// to tolerate transient network errors before propagating the failure.
|
||||
const mcpRequestMaxRetries = 3
|
||||
|
||||
// CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors to avoid
|
||||
// false negatives caused by momentary network issues.
|
||||
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
|
||||
if err == nil {
|
||||
return status, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("check CLI auth status failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
url := GetMCPBaseURL() + CLIAuthEnabledPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -545,7 +1009,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
}
|
||||
|
||||
// GetSuperAdmins fetches the list of corp super admins.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doGetSuperAdmins(ctx, accessToken)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("get super admins failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
url := GetMCPBaseURL() + SuperAdminPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -572,7 +1056,27 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
}
|
||||
|
||||
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("send CLI auth apply failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -608,7 +1112,27 @@ type ClientIDResponse struct {
|
||||
|
||||
// FetchClientIDFromMCP fetches the CLI client ID from MCP server.
|
||||
// This is used when no client ID is provided via flags, config, or env vars.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
id, err := doFetchClientIDFromMCP(ctx)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", fmt.Errorf("fetch client ID failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
url := GetMCPBaseURL() + ClientIDPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -234,18 +234,18 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
// Check CLI auth enabled status
|
||||
// Check CLI auth enabled status (fail-closed: treat errors as disabled)
|
||||
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
|
||||
cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled
|
||||
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
|
||||
|
||||
// Update CLI auth disabled state
|
||||
callbackTokenMu.Lock()
|
||||
callbackAuthDisabled = cliAuthDisabled
|
||||
callbackAuthDisabled = !cliAuthEnabled
|
||||
callbackTokenMu.Unlock()
|
||||
|
||||
// Display appropriate HTML based on CLI auth status
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if cliAuthDisabled {
|
||||
if !cliAuthEnabled {
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, successHTML)
|
||||
@@ -256,7 +256,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
// Notify main goroutine with full result
|
||||
select {
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: cliAuthDisabled}:
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/security"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const secureDataFile = ".data"
|
||||
|
||||
@@ -25,12 +25,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -26,10 +26,10 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -88,7 +87,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
title := flagOrFallback(cmd, "title", "subject", "content")
|
||||
title := cmdutil.FlagOrFallback(cmd, "title", "subject", "content")
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return apperrors.NewValidation("--title is required")
|
||||
}
|
||||
@@ -103,7 +102,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
"executorIds": executorIds,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("due"); v != "" {
|
||||
ms, err := parseISOTimeToMillis("due", v)
|
||||
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -270,7 +269,7 @@ func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
inner["subject"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("due"); v != "" {
|
||||
ms, err := parseISOTimeToMillis("due", v)
|
||||
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -444,16 +443,6 @@ func newTodoTaskDeleteCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────
|
||||
|
||||
// flagOrFallback returns the first non-empty value among the given flag names.
|
||||
func flagOrFallback(cmd *cobra.Command, names ...string) string {
|
||||
for _, name := range names {
|
||||
if v, _ := cmd.Flags().GetString(name); strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseExecutorIds splits "id1,id2" into []string for the MCP executorIds array.
|
||||
func parseExecutorIds(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
@@ -470,25 +459,6 @@ func parseExecutorIds(s string) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// parseISOTimeToMillis parses an ISO-8601 datetime string and returns Unix
|
||||
// milliseconds. It supports timezone offsets (e.g. +08:00) and UTC "Z" suffix.
|
||||
func parseISOTimeToMillis(flagName, value string) (int64, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range formats {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
}
|
||||
return 0, apperrors.NewValidation(
|
||||
fmt.Sprintf("--%s format error, use ISO-8601 e.g. 2026-03-10T18:00:00+08:00", flagName),
|
||||
)
|
||||
}
|
||||
|
||||
// ── list pagination helpers ────────────────────────────────
|
||||
|
||||
func normalizePage(raw string) string {
|
||||
|
||||
@@ -155,5 +155,20 @@
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
|
||||
"附件工作流": "Attachment workflow",
|
||||
"页码 (必填)": "page number (required)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
|
||||
" 请检查网络连接后重试。": " Please check your network connection and retry.",
|
||||
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
|
||||
" 组织主管理员:": " Organization super admins: ",
|
||||
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
|
||||
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " Admin settings: https://open-dev.dingtalk.com/fe/old#/developerSettings",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
|
||||
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
|
||||
"等待管理员审批中": "Waiting for admin approval",
|
||||
"等待提交申请中": "Waiting to submit request",
|
||||
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk"
|
||||
}
|
||||
|
||||
@@ -155,5 +155,20 @@
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
|
||||
"附件工作流": "附件工作流",
|
||||
"页码 (必填)": "页码 (必填)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
|
||||
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
|
||||
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
|
||||
" 组织主管理员:": " 组织主管理员:",
|
||||
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
|
||||
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||
"等待管理员审批中": "等待管理员审批中",
|
||||
"等待提交申请中": "等待提交申请中",
|
||||
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -26,8 +26,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
package output
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
|
||||
|
||||
// SanitizeForTerminal strips ANSI escape sequences, control characters, and
|
||||
// dangerous Unicode from text before it is printed to a terminal.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// ParamNameHandler performs fuzzy correction on flag names that are
|
||||
@@ -101,7 +102,7 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
|
||||
ambiguous := false
|
||||
|
||||
for _, candidate := range candidates {
|
||||
dist := levenshtein(bare, candidate)
|
||||
dist := cmdutil.LevenshteinDist(bare, candidate)
|
||||
if dist < bestDist {
|
||||
bestDist = dist
|
||||
bestMatch = candidate
|
||||
@@ -117,48 +118,3 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
|
||||
|
||||
return "--" + bestMatch + suffix, true
|
||||
}
|
||||
|
||||
// levenshtein computes the edit distance between two strings using
|
||||
// the standard dynamic programming approach with O(min(m,n)) space.
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
// Ensure a is the shorter string for O(min) space.
|
||||
if la > lb {
|
||||
a, b = b, a
|
||||
la, lb = lb, la
|
||||
}
|
||||
|
||||
prev := make([]int, la+1)
|
||||
curr := make([]int, la+1)
|
||||
for i := range prev {
|
||||
prev[i] = i
|
||||
}
|
||||
|
||||
for j := 1; j <= lb; j++ {
|
||||
curr[0] = j
|
||||
for i := 1; i <= la; i++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[i] = min(
|
||||
prev[i]+1, // deletion
|
||||
curr[i-1]+1, // insertion
|
||||
prev[i-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[la]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
@@ -40,12 +41,11 @@ func TestLevenshtein(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.a+"→"+tt.b, func(t *testing.T) {
|
||||
got := levenshtein(tt.a, tt.b)
|
||||
got := cmdutil.LevenshteinDist(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
t.Errorf("LevenshteinDist(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
// Verify symmetry.
|
||||
gotRev := levenshtein(tt.b, tt.a)
|
||||
gotRev := cmdutil.LevenshteinDist(tt.b, tt.a)
|
||||
if gotRev != got {
|
||||
t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// DataFileName is the encrypted token data file name.
|
||||
|
||||
@@ -30,11 +30,11 @@ import (
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadConfig holds download configuration.
|
||||
type DownloadConfig struct {
|
||||
MaxRetries int
|
||||
Timeout time.Duration
|
||||
ProgressCallback func(downloaded, total int64)
|
||||
ProgressInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultDownloadConfig returns sensible download defaults.
|
||||
func DefaultDownloadConfig() *DownloadConfig {
|
||||
return &DownloadConfig{
|
||||
MaxRetries: 3,
|
||||
Timeout: 10 * time.Minute,
|
||||
ProgressInterval: 200 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
// Download fetches url to destPath with default config.
|
||||
func Download(url, destPath string) (int64, error) {
|
||||
return DownloadWithConfig(context.Background(), url, destPath, DefaultDownloadConfig())
|
||||
}
|
||||
|
||||
// DownloadWithProgress downloads a file and reports progress.
|
||||
func DownloadWithProgress(ctx context.Context, url, destPath string, showProgress func(percent float64, downloaded, total int64)) (int64, error) {
|
||||
cfg := DefaultDownloadConfig()
|
||||
cfg.ProgressCallback = func(downloaded, total int64) {
|
||||
if showProgress != nil && total > 0 {
|
||||
percent := float64(downloaded) / float64(total) * 100
|
||||
showProgress(percent, downloaded, total)
|
||||
}
|
||||
}
|
||||
return DownloadWithConfig(ctx, url, destPath, cfg)
|
||||
}
|
||||
|
||||
// DownloadWithConfig fetches a file with custom configuration and retries.
|
||||
func DownloadWithConfig(ctx context.Context, url, destPath string, cfg *DownloadConfig) (int64, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return 0, fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
maxRetries := cfg.MaxRetries
|
||||
if maxRetries < 1 {
|
||||
maxRetries = 1
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Second * time.Duration(1<<uint(attempt-1))
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
n, err := doDownload(ctx, url, destPath, cfg)
|
||||
if err == nil {
|
||||
return n, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
if !isRetriable(err) {
|
||||
return 0, err
|
||||
}
|
||||
os.Remove(destPath)
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("下载失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func doDownload(ctx context.Context, url, destPath string, cfg *DownloadConfig) (int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
client := &http.Client{}
|
||||
if cfg.Timeout > 0 {
|
||||
client.Timeout = cfg.Timeout
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("下载失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("下载失败 (HTTP %d): %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
var writer io.Writer = out
|
||||
if cfg.ProgressCallback != nil {
|
||||
pw := &progressWriter{
|
||||
writer: out,
|
||||
total: resp.ContentLength,
|
||||
callback: cfg.ProgressCallback,
|
||||
interval: cfg.ProgressInterval,
|
||||
lastCallback: time.Now(),
|
||||
}
|
||||
defer pw.finalProgress()
|
||||
writer = pw
|
||||
}
|
||||
|
||||
n, err := io.Copy(writer, resp.Body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// progressWriter wraps an io.Writer and reports download progress.
|
||||
type progressWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
downloaded int64
|
||||
callback func(downloaded, total int64)
|
||||
interval time.Duration
|
||||
lastCallback time.Time
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.downloaded += int64(n)
|
||||
|
||||
now := time.Now()
|
||||
if pw.callback != nil && now.Sub(pw.lastCallback) >= pw.interval {
|
||||
pw.callback(pw.downloaded, pw.total)
|
||||
pw.lastCallback = now
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (pw *progressWriter) finalProgress() {
|
||||
if pw.callback != nil {
|
||||
pw.callback(pw.downloaded, pw.total)
|
||||
}
|
||||
}
|
||||
|
||||
func isRetriable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if os.IsTimeout(err) {
|
||||
return true
|
||||
}
|
||||
errStr := err.Error()
|
||||
for _, pattern := range []string{"connection reset", "connection refused", "timeout", "temporary failure", "eof"} {
|
||||
if strings.Contains(strings.ToLower(errStr), pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package upgrade provides self-update functionality for the DWS CLI
|
||||
// using GitHub Releases as the data source.
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
gitHubAPIBase = "https://api.github.com"
|
||||
defaultOwner = "DingTalk-Real-AI"
|
||||
defaultRepo = "dingtalk-workspace-cli"
|
||||
httpTimeout = 30 * time.Second
|
||||
userAgent = "DWS-CLI-Upgrade/1.0"
|
||||
skillsZipName = "dws-skills.zip"
|
||||
checksumsName = "checksums.txt"
|
||||
)
|
||||
|
||||
// GitHubRelease represents a single release from the GitHub Releases API.
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Assets []GitHubAsset `json:"assets"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// GitHubAsset represents a release asset (downloadable file).
|
||||
type GitHubAsset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// ReleaseInfo is the simplified view of a release used throughout the upgrade flow.
|
||||
type ReleaseInfo struct {
|
||||
Version string
|
||||
Date string
|
||||
Changelog string
|
||||
Prerelease bool
|
||||
HTMLURL string
|
||||
Assets []GitHubAsset
|
||||
}
|
||||
|
||||
// VersionEntry represents a single version in the version list.
|
||||
type VersionEntry struct {
|
||||
Version string
|
||||
Date string
|
||||
Changelog string
|
||||
Prerelease bool
|
||||
}
|
||||
|
||||
// Client communicates with the GitHub Releases API.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
owner string
|
||||
repo string
|
||||
baseURL string // overridable for testing or mirrors
|
||||
}
|
||||
|
||||
// NewClient creates a GitHub release client with default settings.
|
||||
func NewClient() *Client {
|
||||
baseURL := gitHubAPIBase
|
||||
if env := os.Getenv("DWS_UPGRADE_URL"); env != "" {
|
||||
baseURL = strings.TrimRight(env, "/")
|
||||
}
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: httpTimeout},
|
||||
owner: defaultOwner,
|
||||
repo: defaultRepo,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithBaseURL creates a client with a custom base URL (for testing).
|
||||
func NewClientWithBaseURL(baseURL string) *Client {
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: httpTimeout},
|
||||
owner: defaultOwner,
|
||||
repo: defaultRepo,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLatestRelease returns the latest non-draft release.
|
||||
func (c *Client) FetchLatestRelease() (*ReleaseInfo, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", c.baseURL, c.owner, c.repo)
|
||||
|
||||
var gh GitHubRelease
|
||||
if err := c.getJSON(url, &gh); err != nil {
|
||||
return nil, fmt.Errorf("获取最新版本失败: %w", err)
|
||||
}
|
||||
|
||||
return ghReleaseToInfo(&gh), nil
|
||||
}
|
||||
|
||||
// FetchReleaseByTag returns the release for a specific tag (e.g. "v1.0.5").
|
||||
func (c *Client) FetchReleaseByTag(tag string) (*ReleaseInfo, error) {
|
||||
if !strings.HasPrefix(tag, "v") {
|
||||
tag = "v" + tag
|
||||
}
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", c.baseURL, c.owner, c.repo, tag)
|
||||
|
||||
var gh GitHubRelease
|
||||
if err := c.getJSON(url, &gh); err != nil {
|
||||
return nil, fmt.Errorf("获取版本 %s 失败: %w", tag, err)
|
||||
}
|
||||
|
||||
return ghReleaseToInfo(&gh), nil
|
||||
}
|
||||
|
||||
// FetchAllReleases returns all non-draft releases, newest first.
|
||||
func (c *Client) FetchAllReleases() ([]VersionEntry, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=100", c.baseURL, c.owner, c.repo)
|
||||
|
||||
var ghReleases []GitHubRelease
|
||||
if err := c.getJSON(url, &ghReleases); err != nil {
|
||||
return nil, fmt.Errorf("获取版本列表失败: %w", err)
|
||||
}
|
||||
|
||||
var versions []VersionEntry
|
||||
for _, gh := range ghReleases {
|
||||
if gh.Draft {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, VersionEntry{
|
||||
Version: stripV(gh.TagName),
|
||||
Date: formatDate(gh.PublishedAt),
|
||||
Changelog: gh.Body,
|
||||
Prerelease: gh.Prerelease,
|
||||
})
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
// FindBinaryAsset locates the platform-specific binary archive from the release assets.
|
||||
// Pattern: dws-{os}-{arch}.tar.gz (or .zip for windows).
|
||||
func FindBinaryAsset(assets []GitHubAsset) (*GitHubAsset, error) {
|
||||
return FindBinaryAssetFor(assets, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// FindBinaryAssetFor locates the binary archive for a specific platform.
|
||||
func FindBinaryAssetFor(assets []GitHubAsset, goos, goarch string) (*GitHubAsset, error) {
|
||||
ext := ".tar.gz"
|
||||
if goos == "windows" {
|
||||
ext = ".zip"
|
||||
}
|
||||
target := fmt.Sprintf("dws-%s-%s%s", goos, goarch, ext)
|
||||
|
||||
for i := range assets {
|
||||
if assets[i].Name == target {
|
||||
return &assets[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("当前平台 %s/%s 没有可用的预编译二进制 (需要 %s)", goos, goarch, target)
|
||||
}
|
||||
|
||||
// FindSkillsAsset locates the dws-skills.zip asset.
|
||||
func FindSkillsAsset(assets []GitHubAsset) *GitHubAsset {
|
||||
for i := range assets {
|
||||
if assets[i].Name == skillsZipName {
|
||||
return &assets[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindChecksumsAsset locates the checksums.txt asset.
|
||||
func FindChecksumsAsset(assets []GitHubAsset) *GitHubAsset {
|
||||
for i := range assets {
|
||||
if assets[i].Name == checksumsName {
|
||||
return &assets[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractDigestSHA256 extracts the hex hash from a GitHub asset digest field.
|
||||
// GitHub format: "sha256:abcdef1234..."
|
||||
func ExtractDigestSHA256(digest string) string {
|
||||
if strings.HasPrefix(digest, "sha256:") {
|
||||
return digest[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getJSON performs a GET request and decodes the JSON response.
|
||||
func (c *Client) getJSON(url string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
if token := githubToken(); token != "" {
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法连接到 GitHub: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining == "0" {
|
||||
return fmt.Errorf("GitHub API 请求频率超限。设置 GITHUB_TOKEN 或 GH_TOKEN 环境变量可提升限额")
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("未找到 (404)")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("GitHub API 返回 HTTP %d: %s", resp.StatusCode, truncateBytes(body, 200))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
|
||||
return fmt.Errorf("解析 GitHub 响应失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// githubToken returns a GitHub token from environment if available.
|
||||
func githubToken() string {
|
||||
if t := os.Getenv("GITHUB_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := os.Getenv("GH_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ghReleaseToInfo(gh *GitHubRelease) *ReleaseInfo {
|
||||
return &ReleaseInfo{
|
||||
Version: stripV(gh.TagName),
|
||||
Date: formatDate(gh.PublishedAt),
|
||||
Changelog: gh.Body,
|
||||
Prerelease: gh.Prerelease,
|
||||
HTMLURL: gh.HTMLURL,
|
||||
Assets: gh.Assets,
|
||||
}
|
||||
}
|
||||
|
||||
func stripV(tag string) string {
|
||||
return strings.TrimPrefix(tag, "v")
|
||||
}
|
||||
|
||||
func formatDate(published string) string {
|
||||
t, err := time.Parse(time.RFC3339, published)
|
||||
if err != nil {
|
||||
return published
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func truncateBody(body string, maxLen int) string {
|
||||
body = strings.TrimSpace(body)
|
||||
lines := strings.SplitN(body, "\n", 2)
|
||||
first := lines[0]
|
||||
if len(first) > maxLen {
|
||||
return first[:maxLen-3] + "..."
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func truncateBytes(b []byte, max int) string {
|
||||
if len(b) > max {
|
||||
return string(b[:max]) + "..."
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchLatestRelease(t *testing.T) {
|
||||
release := GitHubRelease{
|
||||
TagName: "v1.0.6",
|
||||
Name: "v1.0.6",
|
||||
Body: "Bug fixes and improvements",
|
||||
Prerelease: false,
|
||||
Draft: false,
|
||||
PublishedAt: "2026-04-01T09:29:05Z",
|
||||
Assets: []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz", Size: 3942308, BrowserDownloadURL: "https://example.com/dws-darwin-arm64.tar.gz"},
|
||||
{Name: "dws-skills.zip", Size: 50000, BrowserDownloadURL: "https://example.com/dws-skills.zip"},
|
||||
{Name: "checksums.txt", Size: 534, BrowserDownloadURL: "https://example.com/checksums.txt"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/latest" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(release)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
info, err := client.FetchLatestRelease()
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLatestRelease() error = %v", err)
|
||||
}
|
||||
|
||||
if info.Version != "1.0.6" {
|
||||
t.Errorf("Version = %q, want %q", info.Version, "1.0.6")
|
||||
}
|
||||
if info.Date != "2026-04-01" {
|
||||
t.Errorf("Date = %q, want %q", info.Date, "2026-04-01")
|
||||
}
|
||||
if len(info.Assets) != 3 {
|
||||
t.Errorf("len(Assets) = %d, want 3", len(info.Assets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReleaseByTag(t *testing.T) {
|
||||
release := GitHubRelease{
|
||||
TagName: "v1.0.5",
|
||||
PublishedAt: "2026-03-25T10:00:00Z",
|
||||
Assets: []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz", Size: 3800000, BrowserDownloadURL: "https://example.com/dws-darwin-arm64.tar.gz"},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(release)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
|
||||
// Without "v" prefix
|
||||
info, err := client.FetchReleaseByTag("1.0.5")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReleaseByTag() error = %v", err)
|
||||
}
|
||||
if info.Version != "1.0.5" {
|
||||
t.Errorf("Version = %q, want %q", info.Version, "1.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllReleases(t *testing.T) {
|
||||
releases := []GitHubRelease{
|
||||
{TagName: "v1.0.6", PublishedAt: "2026-04-01T09:00:00Z", Draft: false, Prerelease: false},
|
||||
{TagName: "v1.0.6-beta", PublishedAt: "2026-03-28T09:00:00Z", Draft: false, Prerelease: true},
|
||||
{TagName: "v1.0.5", PublishedAt: "2026-03-25T09:00:00Z", Draft: false, Prerelease: false},
|
||||
{TagName: "v1.0.4-draft", PublishedAt: "2026-03-20T09:00:00Z", Draft: true, Prerelease: false},
|
||||
}
|
||||
|
||||
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("FetchAllReleases() error = %v", err)
|
||||
}
|
||||
|
||||
// Draft should be filtered out
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("len(versions) = %d, want 3", len(versions))
|
||||
}
|
||||
if versions[0].Version != "1.0.6" {
|
||||
t.Errorf("versions[0].Version = %q, want %q", versions[0].Version, "1.0.6")
|
||||
}
|
||||
if versions[1].Prerelease != true {
|
||||
t.Errorf("versions[1].Prerelease = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBinaryAsset(t *testing.T) {
|
||||
assets := []GitHubAsset{
|
||||
{Name: "checksums.txt", Size: 534},
|
||||
{Name: "dws-darwin-amd64.tar.gz", Size: 4276643},
|
||||
{Name: "dws-darwin-arm64.tar.gz", Size: 3942308},
|
||||
{Name: "dws-linux-amd64.tar.gz", Size: 4394696},
|
||||
{Name: "dws-windows-amd64.zip", Size: 4500000},
|
||||
{Name: "dws-skills.zip", Size: 50000},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
goos, goarch string
|
||||
wantName string
|
||||
wantErr bool
|
||||
}{
|
||||
{"darwin", "arm64", "dws-darwin-arm64.tar.gz", false},
|
||||
{"darwin", "amd64", "dws-darwin-amd64.tar.gz", false},
|
||||
{"linux", "amd64", "dws-linux-amd64.tar.gz", false},
|
||||
{"windows", "amd64", "dws-windows-amd64.zip", false},
|
||||
{"freebsd", "amd64", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
asset, err := FindBinaryAssetFor(assets, tt.goos, tt.goarch)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("FindBinaryAssetFor(%s, %s) expected error", tt.goos, tt.goarch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("FindBinaryAssetFor(%s, %s) error = %v", tt.goos, tt.goarch, err)
|
||||
continue
|
||||
}
|
||||
if asset.Name != tt.wantName {
|
||||
t.Errorf("FindBinaryAssetFor(%s, %s).Name = %q, want %q", tt.goos, tt.goarch, asset.Name, tt.wantName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSkillsAsset(t *testing.T) {
|
||||
assets := []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz"},
|
||||
{Name: "dws-skills.zip", Size: 50000},
|
||||
{Name: "checksums.txt"},
|
||||
}
|
||||
|
||||
skill := FindSkillsAsset(assets)
|
||||
if skill == nil {
|
||||
t.Fatal("FindSkillsAsset() returned nil")
|
||||
}
|
||||
if skill.Name != "dws-skills.zip" {
|
||||
t.Errorf("Name = %q, want %q", skill.Name, "dws-skills.zip")
|
||||
}
|
||||
|
||||
// No skills asset
|
||||
noSkills := []GitHubAsset{{Name: "dws-darwin-arm64.tar.gz"}}
|
||||
if got := FindSkillsAsset(noSkills); got != nil {
|
||||
t.Errorf("FindSkillsAsset() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChecksumsAsset(t *testing.T) {
|
||||
assets := []GitHubAsset{
|
||||
{Name: "dws-darwin-arm64.tar.gz"},
|
||||
{Name: "checksums.txt", Size: 534},
|
||||
}
|
||||
|
||||
checksums := FindChecksumsAsset(assets)
|
||||
if checksums == nil {
|
||||
t.Fatal("FindChecksumsAsset() returned nil")
|
||||
}
|
||||
if checksums.Name != "checksums.txt" {
|
||||
t.Errorf("Name = %q, want %q", checksums.Name, "checksums.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDigestSHA256(t *testing.T) {
|
||||
tests := []struct {
|
||||
digest string
|
||||
want string
|
||||
}{
|
||||
{"sha256:abcdef1234567890", "abcdef1234567890"},
|
||||
{"sha256:", ""},
|
||||
{"md5:abcdef", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ExtractDigestSHA256(tt.digest)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExtractDigestSHA256(%q) = %q, want %q", tt.digest, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClientWithBaseURL(server.URL)
|
||||
_, err := client.FetchLatestRelease()
|
||||
if err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
if got := err.Error(); !contains(got, "频率超限") {
|
||||
t.Errorf("error = %q, want to contain rate limit message", got)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Permission constants following Unix best practices.
|
||||
const (
|
||||
dirPermSecure os.FileMode = 0o700
|
||||
dirPermShared os.FileMode = 0o755
|
||||
filePermBinary os.FileMode = 0o755
|
||||
filePermConfig os.FileMode = 0o644
|
||||
)
|
||||
|
||||
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
|
||||
// Kept in sync with build/npm/install.js AGENT_DIRS.
|
||||
// The first entry (.agents/skills) is always updated; subsequent entries are
|
||||
// only updated when their parent directory already exists.
|
||||
var knownSkillDirs = []string{
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
".cline/skills",
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
}
|
||||
|
||||
// skillDirBlacklist contains parent directories whose skills are managed by
|
||||
// external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade.
|
||||
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.
|
||||
//
|
||||
// Strategy (matches npm install.js installSkillsToHomes):
|
||||
// - ~/.agents/skills/dws/ is ALWAYS updated (primary install location)
|
||||
// - Other agent dirs (claude, cursor, ...) are updated only when the parent
|
||||
// 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/
|
||||
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
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
|
||||
}
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
|
||||
}
|
||||
|
||||
// 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 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})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
|
||||
// It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md).
|
||||
func LocateSkillMD(extractDir string) string {
|
||||
// Check nested: {extractDir}/dws/SKILL.md
|
||||
nested := filepath.Join(extractDir, "dws", "SKILL.md")
|
||||
if _, err := os.Stat(nested); err == nil {
|
||||
return filepath.Join(extractDir, "dws")
|
||||
}
|
||||
|
||||
// Check flat: {extractDir}/SKILL.md
|
||||
flat := filepath.Join(extractDir, "SKILL.md")
|
||||
if _, err := os.Stat(flat); err == nil {
|
||||
return extractDir
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// EnsureUpgradeDirectories creates the directories needed for upgrade operations.
|
||||
func EnsureUpgradeDirectories() error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dirs := []struct {
|
||||
path string
|
||||
perm os.FileMode
|
||||
}{
|
||||
{filepath.Join(homeDir, ".dws"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "data"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "data", "backups"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "cache"), dirPermSecure},
|
||||
{filepath.Join(homeDir, ".dws", "cache", "downloads"), dirPermSecure},
|
||||
}
|
||||
|
||||
for _, d := range dirs {
|
||||
if err := ensureDir(d.path, d.perm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadCacheDir returns the path for temporary downloads during upgrade.
|
||||
func DownloadCacheDir() string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
return filepath.Join(homeDir, ".dws", "cache", "downloads")
|
||||
}
|
||||
|
||||
// CurrentBinaryPath returns the resolved path of the currently running binary.
|
||||
func CurrentBinaryPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(exe)
|
||||
}
|
||||
|
||||
// BinaryName returns the platform-specific binary name.
|
||||
func BinaryName() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "dws.exe"
|
||||
}
|
||||
return "dws"
|
||||
}
|
||||
|
||||
func isBlacklisted(agentDir string) bool {
|
||||
for _, bl := range skillDirBlacklist {
|
||||
// agentDir is like ".real/skills" — check if it starts with a blacklisted prefix
|
||||
if len(agentDir) >= len(bl) && agentDir[:len(bl)] == bl {
|
||||
next := len(bl)
|
||||
if next == len(agentDir) || agentDir[next] == '/' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureDir(path string, perm os.FileMode) error {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return os.MkdirAll(path, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != perm {
|
||||
if info.Mode().Perm()&^perm != 0 {
|
||||
return os.Chmod(path, perm)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsBlacklisted(t *testing.T) {
|
||||
tests := []struct {
|
||||
dir string
|
||||
want bool
|
||||
}{
|
||||
{".real/skills", true},
|
||||
{".real", true},
|
||||
{".agents/skills", false},
|
||||
{".claude/skills", false},
|
||||
{".cursor/skills", false},
|
||||
{".realtime/skills", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isBlacklisted(tt.dir)
|
||||
if got != tt.want {
|
||||
t.Errorf("isBlacklisted(%q) = %v, want %v", tt.dir, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocateSkillMD(t *testing.T) {
|
||||
// Flat layout
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# skill"), 0644)
|
||||
|
||||
result := LocateSkillMD(dir)
|
||||
if result != dir {
|
||||
t.Errorf("flat layout: LocateSkillMD() = %q, want %q", result, dir)
|
||||
}
|
||||
|
||||
// Nested layout
|
||||
dir2 := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir2, "dws"), 0755)
|
||||
os.WriteFile(filepath.Join(dir2, "dws", "SKILL.md"), []byte("# skill"), 0644)
|
||||
|
||||
result2 := LocateSkillMD(dir2)
|
||||
want2 := filepath.Join(dir2, "dws")
|
||||
if result2 != want2 {
|
||||
t.Errorf("nested layout: LocateSkillMD() = %q, want %q", result2, want2)
|
||||
}
|
||||
|
||||
// No SKILL.md
|
||||
dir3 := t.TempDir()
|
||||
result3 := LocateSkillMD(dir3)
|
||||
if result3 != "" {
|
||||
t.Errorf("empty dir: LocateSkillMD() = %q, want empty", result3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeSkillLocations(t *testing.T) {
|
||||
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)
|
||||
|
||||
result, err := UpgradeSkillLocations(skillSrc)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
|
||||
succeeded := result.Succeeded()
|
||||
if len(succeeded) == 0 {
|
||||
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
|
||||
|
||||
found := false
|
||||
for _, d := range succeeded {
|
||||
if d.Dir == primaryDest {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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)
|
||||
}
|
||||
|
||||
skillMDPath := filepath.Join(primaryDest, "SKILL.md")
|
||||
if _, err := os.Stat(skillMDPath); os.IsNotExist(err) {
|
||||
t.Errorf("SKILL.md not found at %s", skillMDPath)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReplaceSelf atomically replaces the currently running binary with newBinaryPath.
|
||||
//
|
||||
// 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 {
|
||||
return fmt.Errorf("无法获取当前二进制路径: %w", err)
|
||||
}
|
||||
currentExe, err = filepath.EvalSymlinks(currentExe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析符号链接: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(newBinaryPath, filePermBinary); err != nil {
|
||||
return fmt.Errorf("设置权限失败: %w", 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 {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开 zip 失败: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
targetDir = filepath.Clean(targetDir)
|
||||
for _, f := range r.File {
|
||||
destPath := filepath.Join(targetDir, f.Name)
|
||||
rel, err := filepath.Rel(targetDir, destPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
continue // zip-slip guard
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(destPath, dirPermShared)
|
||||
continue
|
||||
}
|
||||
if err := extractZipEntry(f, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindBinaryInDir recursively finds the dws binary in an extracted directory.
|
||||
func FindBinaryInDir(dir string) string {
|
||||
candidates := []string{
|
||||
filepath.Join(dir, "dws"),
|
||||
filepath.Join(dir, "dws.exe"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
var found string
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := info.Name()
|
||||
if name == "dws" || name == "dws.exe" {
|
||||
found = path
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func extractZipEntry(f *zip.File, destPath string) error {
|
||||
os.MkdirAll(filepath.Dir(destPath), dirPermShared)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 zip 条目失败: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, rc)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, perm os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func syncParentDir(path string) {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.Sync()
|
||||
f.Close()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultMaxBackups = 5
|
||||
|
||||
// BackupInfo contains information about a single backup.
|
||||
type BackupInfo struct {
|
||||
Path string `json:"path"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// RollbackManager manages backup and rollback operations.
|
||||
type RollbackManager struct {
|
||||
backupDir string
|
||||
maxBackups int
|
||||
}
|
||||
|
||||
// NewRollbackManager creates a rollback manager using the standard backup directory.
|
||||
func NewRollbackManager() *RollbackManager {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
homeDir = "."
|
||||
}
|
||||
return &RollbackManager{
|
||||
backupDir: filepath.Join(homeDir, ".dws", "data", "backups"),
|
||||
maxBackups: defaultMaxBackups,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRollbackManagerWithDir creates a rollback manager with a custom directory.
|
||||
func NewRollbackManagerWithDir(backupDir string) *RollbackManager {
|
||||
return &RollbackManager{
|
||||
backupDir: backupDir,
|
||||
maxBackups: defaultMaxBackups,
|
||||
}
|
||||
}
|
||||
|
||||
// Backup creates a backup of the currently running binary.
|
||||
// Returns the backup directory path.
|
||||
func (r *RollbackManager) Backup(currentVersion string) (string, error) {
|
||||
currentExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法获取当前二进制路径: %w", err)
|
||||
}
|
||||
currentExe, err = filepath.EvalSymlinks(currentExe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法解析符号链接: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(currentExe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法读取当前二进制信息: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(r.backupDir, dirPermSecure); err != nil {
|
||||
return "", fmt.Errorf("创建备份目录失败: %w", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
backupSetName := fmt.Sprintf("v%s-%s", currentVersion, timestamp)
|
||||
backupSetPath := filepath.Join(r.backupDir, backupSetName)
|
||||
|
||||
if err := os.MkdirAll(backupSetPath, dirPermSecure); err != nil {
|
||||
return "", fmt.Errorf("创建备份集目录失败: %w", err)
|
||||
}
|
||||
|
||||
binaryBackupDir := filepath.Join(backupSetPath, "binary")
|
||||
if err := os.MkdirAll(binaryBackupDir, dirPermSecure); err != nil {
|
||||
return "", fmt.Errorf("创建二进制备份目录失败: %w", err)
|
||||
}
|
||||
|
||||
binaryBackupPath := filepath.Join(binaryBackupDir, filepath.Base(currentExe))
|
||||
if err := copyFile(currentExe, binaryBackupPath, info.Mode()); err != nil {
|
||||
return "", fmt.Errorf("备份二进制失败: %w", err)
|
||||
}
|
||||
|
||||
backupInfo := BackupInfo{
|
||||
Path: backupSetPath,
|
||||
BinaryPath: binaryBackupPath,
|
||||
Version: currentVersion,
|
||||
CreatedAt: time.Now(),
|
||||
Size: info.Size(),
|
||||
}
|
||||
r.saveBackupInfo(backupInfo)
|
||||
|
||||
return backupSetPath, nil
|
||||
}
|
||||
|
||||
// Rollback restores the most recent backup.
|
||||
func (r *RollbackManager) Rollback() error {
|
||||
backups, err := r.ListBackups()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(backups) == 0 {
|
||||
return fmt.Errorf("没有可用的备份")
|
||||
}
|
||||
return r.RollbackTo(backups[0])
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("无法获取当前二进制路径: %w", err)
|
||||
}
|
||||
currentExe, err = filepath.EvalSymlinks(currentExe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析符号链接: %w", err)
|
||||
}
|
||||
|
||||
binaryBackupPath := backup.BinaryPath
|
||||
if binaryBackupPath == "" {
|
||||
binaryBackupPath = filepath.Join(backup.Path, "binary", BinaryName())
|
||||
}
|
||||
|
||||
if _, err := os.Stat(binaryBackupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("备份文件不存在: %s", binaryBackupPath)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
if err := replaceExeFile(tmpPath, currentExe); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("恢复二进制失败: %w", err)
|
||||
}
|
||||
|
||||
syncFileData(currentExe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackups returns all available backups, newest first.
|
||||
func (r *RollbackManager) ListBackups() ([]BackupInfo, error) {
|
||||
entries, err := os.ReadDir(r.backupDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取备份目录失败: %w", err)
|
||||
}
|
||||
|
||||
var backups []BackupInfo
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
backupPath := filepath.Join(r.backupDir, entry.Name())
|
||||
info, err := r.loadBackupInfo(backupPath)
|
||||
if err != nil {
|
||||
fi, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
continue
|
||||
}
|
||||
info = BackupInfo{
|
||||
Path: backupPath,
|
||||
Version: parseVersionFromBackupName(entry.Name()),
|
||||
CreatedAt: fi.ModTime(),
|
||||
}
|
||||
}
|
||||
backups = append(backups, info)
|
||||
}
|
||||
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].CreatedAt.After(backups[j].CreatedAt)
|
||||
})
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// Cleanup removes old backups, keeping only the most recent N.
|
||||
func (r *RollbackManager) Cleanup(keep int) error {
|
||||
backups, err := r.ListBackups()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if keep <= 0 {
|
||||
keep = r.maxBackups
|
||||
}
|
||||
for i := keep; i < len(backups); i++ {
|
||||
os.RemoveAll(backups[i].Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RollbackManager) saveBackupInfo(info BackupInfo) {
|
||||
infoPath := filepath.Join(info.Path, "info.json")
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.WriteFile(infoPath, data, filePermConfig)
|
||||
}
|
||||
|
||||
func (r *RollbackManager) loadBackupInfo(backupSetPath string) (BackupInfo, error) {
|
||||
infoPath := filepath.Join(backupSetPath, "info.json")
|
||||
data, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
var info BackupInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// parseVersionFromBackupName extracts version from "v0.2.7-20260314-100523".
|
||||
func parseVersionFromBackupName(name string) string {
|
||||
if len(name) > 1 && name[0] == 'v' {
|
||||
// Find first '-' after version digits
|
||||
for i := 1; i < len(name); i++ {
|
||||
if name[i] == '-' {
|
||||
// Check if next char is a digit (timestamp), meaning this is the separator
|
||||
if i+1 < len(name) && name[i+1] >= '0' && name[i+1] <= '9' {
|
||||
return name[1:i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return name[1:]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func syncFileData(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.Sync()
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory from src to dst.
|
||||
func copyDir(src, dst string) error {
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
if err := copyDir(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
srcFile.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
srcFile.Close()
|
||||
dstFile.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseVersionFromBackupName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"v1.0.6-20260401-093000", "1.0.6"},
|
||||
{"v0.2.7-20260314-100523", "0.2.7"},
|
||||
{"v1.0.0", "1.0.0"},
|
||||
{"invalid", "unknown"},
|
||||
{"v", "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseVersionFromBackupName(tt.name)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseVersionFromBackupName(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagerBackupAndList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
// Create a fake binary to backup
|
||||
fakeExe := filepath.Join(dir, "dws")
|
||||
os.WriteFile(fakeExe, []byte("#!/bin/sh\necho fake"), 0755)
|
||||
|
||||
// ListBackups on empty dir
|
||||
backups, err := rm.ListBackups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBackups() error = %v", err)
|
||||
}
|
||||
if len(backups) != 0 {
|
||||
t.Errorf("ListBackups() on empty = %d, want 0", len(backups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagerCleanup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rm := NewRollbackManagerWithDir(dir)
|
||||
|
||||
// Create backup directories with proper timestamps in info.json
|
||||
names := []string{"v1.0.1-20260101-010000", "v1.0.2-20260102-020000", "v1.0.3-20260103-030000"}
|
||||
for i, name := range names {
|
||||
backupDir := filepath.Join(dir, name)
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
info := BackupInfo{
|
||||
Path: backupDir,
|
||||
Version: "1.0." + string(rune('1'+i)),
|
||||
CreatedAt: mustParseTime(t, "2026-01-0"+string(rune('1'+i))+"T01:00:00Z"),
|
||||
}
|
||||
rm.saveBackupInfo(info)
|
||||
}
|
||||
|
||||
// Keep only 1 (the newest)
|
||||
if err := rm.Cleanup(1); err != nil {
|
||||
t.Fatalf("Cleanup(1) error = %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("after Cleanup(1), %d entries remain, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifySHA256 verifies a file against its expected SHA256 hash.
|
||||
func VerifySHA256(filePath, expectedHash string) error {
|
||||
actual, err := ComputeSHA256(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expectedHash = strings.ToLower(strings.TrimSpace(expectedHash))
|
||||
if actual != expectedHash {
|
||||
return fmt.Errorf("SHA256 校验失败: 期望 %s..., 实际 %s...", expectedHash[:16], actual[:16])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeSHA256 computes the SHA256 hash of a file.
|
||||
func ComputeSHA256(filePath string) (string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// ParseChecksumFile parses a SHA256SUMS-style file.
|
||||
// Each line: "hash filename" (two spaces or whitespace separated).
|
||||
// Returns a map of filename -> lowercase hex hash.
|
||||
func ParseChecksumFile(content string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
hash := strings.ToLower(parts[0])
|
||||
filename := parts[1]
|
||||
result[filename] = hash
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// VerifyFileFromChecksums verifies a downloaded file against checksums.txt content.
|
||||
func VerifyFileFromChecksums(filePath, filename, checksumsContent string) error {
|
||||
checksums := ParseChecksumFile(checksumsContent)
|
||||
expectedHash, ok := checksums[filename]
|
||||
if !ok {
|
||||
return fmt.Errorf("在校验和文件中未找到 %s", filename)
|
||||
}
|
||||
return VerifySHA256(filePath, expectedHash)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeSHA256(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "testfile")
|
||||
content := []byte("hello world")
|
||||
os.WriteFile(path, content, 0644)
|
||||
|
||||
hash, err := ComputeSHA256(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeSHA256() error = %v", err)
|
||||
}
|
||||
|
||||
expected := sha256.Sum256(content)
|
||||
want := hex.EncodeToString(expected[:])
|
||||
if hash != want {
|
||||
t.Errorf("ComputeSHA256() = %q, want %q", hash, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySHA256(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "testfile")
|
||||
content := []byte("hello world")
|
||||
os.WriteFile(path, content, 0644)
|
||||
|
||||
expected := sha256.Sum256(content)
|
||||
hash := hex.EncodeToString(expected[:])
|
||||
|
||||
if err := VerifySHA256(path, hash); err != nil {
|
||||
t.Errorf("VerifySHA256() with correct hash: error = %v", err)
|
||||
}
|
||||
|
||||
if err := VerifySHA256(path, "0000000000000000000000000000000000000000000000000000000000000000"); err == nil {
|
||||
t.Error("VerifySHA256() with wrong hash: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChecksumFile(t *testing.T) {
|
||||
content := `abc123def456 dws-darwin-arm64.tar.gz
|
||||
fedcba987654 dws-linux-amd64.tar.gz
|
||||
112233445566 dws-skills.zip
|
||||
`
|
||||
|
||||
result := ParseChecksumFile(content)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("len(result) = %d, want 3", len(result))
|
||||
}
|
||||
if result["dws-darwin-arm64.tar.gz"] != "abc123def456" {
|
||||
t.Errorf("darwin hash = %q", result["dws-darwin-arm64.tar.gz"])
|
||||
}
|
||||
if result["dws-skills.zip"] != "112233445566" {
|
||||
t.Errorf("skills hash = %q", result["dws-skills.zip"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyFileFromChecksums(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "myfile.bin")
|
||||
content := []byte("test content")
|
||||
os.WriteFile(path, content, 0644)
|
||||
|
||||
expected := sha256.Sum256(content)
|
||||
hash := hex.EncodeToString(expected[:])
|
||||
checksums := hash + " myfile.bin\nother otherfile.bin\n"
|
||||
|
||||
if err := VerifyFileFromChecksums(path, "myfile.bin", checksums); err != nil {
|
||||
t.Errorf("VerifyFileFromChecksums() with correct hash: error = %v", err)
|
||||
}
|
||||
|
||||
if err := VerifyFileFromChecksums(path, "missing.bin", checksums); err == nil {
|
||||
t.Error("VerifyFileFromChecksums() with missing file: expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CompareVersions compares two semver strings (e.g. "1.0.5", "v1.0.6-beta").
|
||||
// Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||
// Only the numeric major.minor.patch segments are compared; prerelease suffixes
|
||||
// after "-" are stripped before comparison.
|
||||
func CompareVersions(a, b string) int {
|
||||
pa := parseVersionParts(a)
|
||||
pb := parseVersionParts(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if pa[i] < pb[i] {
|
||||
return -1
|
||||
}
|
||||
if pa[i] > pb[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// NeedsUpgrade returns true when remoteVersion is newer than currentVersion.
|
||||
func NeedsUpgrade(currentVersion, remoteVersion string) bool {
|
||||
return CompareVersions(currentVersion, remoteVersion) < 0
|
||||
}
|
||||
|
||||
func parseVersionParts(v string) [3]int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
if idx := strings.IndexByte(v, '-'); idx >= 0 {
|
||||
v = v[:idx]
|
||||
}
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
var result [3]int
|
||||
for i, p := range parts {
|
||||
if i < 3 {
|
||||
result[i], _ = strconv.Atoi(p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"1.0.0", "1.0.0", 0},
|
||||
{"1.0.0", "1.0.1", -1},
|
||||
{"1.0.1", "1.0.0", 1},
|
||||
{"0.2.7", "0.3.0", -1},
|
||||
{"1.0.0", "0.9.9", 1},
|
||||
{"v1.0.6", "1.0.6", 0},
|
||||
{"v1.0.5", "v1.0.6", -1},
|
||||
{"1.0.6-beta", "1.0.6", 0},
|
||||
{"1.0.5-rc1", "1.0.6-beta", -1},
|
||||
{"2.0.0", "1.99.99", 1},
|
||||
{"0.0.1", "0.0.0", 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := CompareVersions(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsUpgrade(t *testing.T) {
|
||||
tests := []struct {
|
||||
current, remote string
|
||||
want bool
|
||||
}{
|
||||
{"v1.0.6", "1.0.6", false},
|
||||
{"v1.0.5", "1.0.6", true},
|
||||
{"v1.0.7", "1.0.6", false},
|
||||
{"v1.0.6", "1.0.7", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := NeedsUpgrade(tt.current, tt.remote)
|
||||
if got != tt.want {
|
||||
t.Errorf("NeedsUpgrade(%q, %q) = %v, want %v", tt.current, tt.remote, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package cli exposes public entry points for the DWS CLI so that external
|
||||
// overlay modules can embed and launch the CLI after customising edition hooks.
|
||||
package cli
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
|
||||
// SetVersion overrides the version, build time and git commit strings
|
||||
// that are displayed by `dws version` and `dws --version`.
|
||||
// Typically called by overlay main.go with values injected via ldflags.
|
||||
func SetVersion(v, buildTime, gitCommit string) {
|
||||
app.SetVersion(v, buildTime, gitCommit)
|
||||
}
|
||||
|
||||
// Execute runs the root CLI command and returns the process exit code.
|
||||
func Execute() int {
|
||||
return app.Execute()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// FlagFixResult holds the result of SuggestFlagFix analysis.
|
||||
type FlagFixResult struct {
|
||||
Suggestion string
|
||||
AutoFixFlag string
|
||||
AutoFixValue string
|
||||
}
|
||||
|
||||
// CommonFlagAliases maps commonly misused flag names to their correct equivalents.
|
||||
var CommonFlagAliases = map[string]string{
|
||||
"json": "format json",
|
||||
"output": "format",
|
||||
"out": "format",
|
||||
"o": "format",
|
||||
"silent": "quiet",
|
||||
"dry": "dry-run",
|
||||
"force": "yes",
|
||||
"f": "yes",
|
||||
"timeout-seconds": "timeout",
|
||||
"device-flow": "device",
|
||||
"deviceflow": "device",
|
||||
}
|
||||
|
||||
// DetectNumericTypeError checks if err is a Cobra/pflag numeric type
|
||||
// validation error. Returns the flag name and the bad value if detected.
|
||||
func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) {
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "strconv.Parse") {
|
||||
return "", "", false
|
||||
}
|
||||
const argPrefix = "invalid argument \""
|
||||
argIdx := strings.Index(msg, argPrefix)
|
||||
if argIdx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
afterArg := msg[argIdx+len(argPrefix):]
|
||||
argEnd := strings.Index(afterArg, "\"")
|
||||
if argEnd < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
badVal := afterArg[:argEnd]
|
||||
|
||||
const marker = "\" for \"--"
|
||||
idx := strings.Index(msg, marker)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
rest := msg[idx+len(marker):]
|
||||
endIdx := strings.Index(rest, "\" flag")
|
||||
if endIdx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return rest[:endIdx], badVal, true
|
||||
}
|
||||
|
||||
// SuggestFlagFix detects flag-value concatenation errors, common flag aliases,
|
||||
// and Levenshtein-close typos.
|
||||
func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
msg := flagErr.Error()
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(msg, prefix)
|
||||
if idx < 0 {
|
||||
return FlagFixResult{}
|
||||
}
|
||||
body := strings.TrimSpace(msg[idx+len(prefix):])
|
||||
|
||||
if alias, ok := CommonFlagAliases[body]; ok {
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s? Run '%s --help' for options", alias, cmd.CommandPath())}
|
||||
}
|
||||
|
||||
var bestFlag, bestValue string
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
name := f.Name
|
||||
if strings.HasPrefix(body, name) && len(body) > len(name) {
|
||||
if len(name) > len(bestFlag) {
|
||||
bestFlag = name
|
||||
bestValue = body[len(name):]
|
||||
}
|
||||
}
|
||||
})
|
||||
if bestFlag != "" {
|
||||
canAutoFix := len(bestValue) > 0
|
||||
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
|
||||
if canAutoFix {
|
||||
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
|
||||
}
|
||||
return FlagFixResult{Suggestion: suggestion}
|
||||
}
|
||||
|
||||
bestName, bestDist := "", 999
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
d := LevenshteinDist(body, f.Name)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
bestName = f.Name
|
||||
}
|
||||
})
|
||||
threshold := LevenshteinThreshold(len(body))
|
||||
if bestDist > 0 && bestDist <= threshold && bestName != "" {
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)}
|
||||
}
|
||||
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())}
|
||||
}
|
||||
|
||||
// LevenshteinThreshold returns the max edit distance allowed based on string length.
|
||||
func LevenshteinThreshold(nameLen int) int {
|
||||
if nameLen <= 3 {
|
||||
return 1
|
||||
}
|
||||
if nameLen <= 8 {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
// LevenshteinDist returns the edit distance between two strings.
|
||||
func LevenshteinDist(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
dp := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
dp[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
prev := dp[0]
|
||||
dp[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
temp := dp[j]
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
dp[j] = min(dp[j]+1, min(dp[j-1]+1, prev+cost))
|
||||
prev = temp
|
||||
}
|
||||
}
|
||||
return dp[lb]
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package cmdutil provides reusable CLI helper functions for Cobra commands.
|
||||
// Both the open-source CLI and private overlays import this package to avoid
|
||||
// duplicating flag validation, time parsing, and UX helpers.
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GroupRunE is a reusable RunE for parent (group) commands that have no
|
||||
// business logic of their own. With args it returns an error listing available
|
||||
// subcommands; without args it shows help.
|
||||
func GroupRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
var names []string
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.Hidden {
|
||||
names = append(names, sub.Name())
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help",
|
||||
args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath())
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint.
|
||||
func HintSubCmd(use, hint string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: use,
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("ambiguous command %q for %q\n hint: %s",
|
||||
use, cmd.Parent().CommandPath(), hint)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MustGetFlag retrieves a string flag value, checking both local and inherited flags.
|
||||
func MustGetFlag(cmd *cobra.Command, name string) string {
|
||||
val, _ := cmd.Flags().GetString(name)
|
||||
if val == "" {
|
||||
val, _ = cmd.InheritedFlags().GetString(name)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// FlagOrFallback reads the primary flag; if empty, falls back through alias
|
||||
// flags in order, returning the first non-empty value.
|
||||
func FlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
|
||||
if v, _ := cmd.Flags().GetString(primary); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if v, _ := cmd.Flags().GetString(alias); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MustFlagOrFallback works like FlagOrFallback but returns an error when all
|
||||
// flags are empty.
|
||||
func MustFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
|
||||
v := FlagOrFallback(cmd, primary, aliases...)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("flag --%s is required\n hint: %s", primary, cmd.Example)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// MustFlagWithHint returns an error with an explicit usage example when the
|
||||
// flag is empty.
|
||||
func MustFlagWithHint(cmd *cobra.Command, name, example string) (string, error) {
|
||||
v := MustGetFlag(cmd, name)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("flag --%s is required\n hint: %s", name, example)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ValidateRequiredFlags checks that all named string flags are non-empty.
|
||||
// Returns a formatted error listing all missing flags, or nil.
|
||||
func ValidateRequiredFlags(cmd *cobra.Command, names ...string) error {
|
||||
var missing []string
|
||||
for _, name := range names {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
if v == "" {
|
||||
missing = append(missing, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("missing required flag(s): %s\n usage: %s\n example:\n%s",
|
||||
strings.Join(missing, ", "), cmd.UseLine(), cmd.Example)
|
||||
}
|
||||
|
||||
// ValidateRequiredFlagWithAliases checks that at least one of the primary flag
|
||||
// or its aliases is non-empty.
|
||||
func ValidateRequiredFlagWithAliases(cmd *cobra.Command, primary string, aliases ...string) error {
|
||||
if FlagOrFallback(cmd, primary, aliases...) != "" {
|
||||
return nil
|
||||
}
|
||||
all := append([]string{primary}, aliases...)
|
||||
return fmt.Errorf("missing required flag: --%s (or %s)\n usage: %s\n example:\n%s",
|
||||
primary, "--"+strings.Join(all[1:], " / --"), cmd.UseLine(), cmd.Example)
|
||||
}
|
||||
|
||||
// ConfirmDelete asks for interactive confirmation before destructive operations.
|
||||
// Returns true if --yes/-y flag is set or the user types "yes"/"y".
|
||||
func ConfirmDelete(cmd *cobra.Command, resourceType, resourceName string) bool {
|
||||
if yes, _ := cmd.Flags().GetBool("yes"); yes {
|
||||
return true
|
||||
}
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--yes" || arg == "-y" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "About to delete %s: %s\n", resourceType, resourceName)
|
||||
fmt.Fprint(cmd.ErrOrStderr(), "Confirm deletion? (yes/no): ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
|
||||
if answer == "yes" || answer == "y" {
|
||||
return true
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FlexTimeLayouts is the ordered list of time formats tried by ParseISOTimeToMillis.
|
||||
var FlexTimeLayouts = []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02",
|
||||
"20060102",
|
||||
}
|
||||
|
||||
// ParseISOTimeToMillis parses a time string into a millisecond Unix timestamp.
|
||||
// Supports RFC3339, UTC Z, timezone-less, space-separated, date-only, and more.
|
||||
// When the input lacks an explicit timezone, Asia/Shanghai is assumed.
|
||||
func ParseISOTimeToMillis(flagName, value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Trim(value, "\"'")
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("flag --%s is required\n hint: example: 2026-03-10T14:00:00+08:00", flagName)
|
||||
}
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
for _, layout := range FlexTimeLayouts {
|
||||
t, err := time.ParseInLocation(layout, value, loc)
|
||||
if err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf(
|
||||
"cannot parse time for --%s (input: %q)\n hint: supported formats: 2026-03-23T14:00:00+08:00, 2026-03-23 14:00:00, 2026-03-23",
|
||||
flagName, value)
|
||||
}
|
||||
|
||||
// ValidateTimeRange checks that endMs is strictly after startMs.
|
||||
func ValidateTimeRange(startMs, endMs int64) error {
|
||||
if endMs <= startMs {
|
||||
return fmt.Errorf("--end must be after --start\n hint: swap the values or adjust the time range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package edition
|
||||
|
||||
// defaultHooks returns the open-source edition defaults.
|
||||
// All function hooks are nil, which the internal code interprets as
|
||||
// "use standard open-source behaviour".
|
||||
func defaultHooks() *Hooks {
|
||||
return &Hooks{
|
||||
Name: "open",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package edition provides an extension-point mechanism that allows private
|
||||
// overlay modules (e.g. an internal distribution) to customise CLI behaviour
|
||||
// without modifying the open-source core. The open-source build uses the
|
||||
// zero-value defaults; an overlay calls Override before Execute.
|
||||
package edition
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ServerInfo describes a static MCP server endpoint.
|
||||
type ServerInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Endpoint string
|
||||
Prefixes []string
|
||||
}
|
||||
|
||||
// ContentBlock is a single content item in a ToolResult.
|
||||
type ContentBlock struct {
|
||||
Type string
|
||||
Text string
|
||||
}
|
||||
|
||||
// ToolResult holds the response from an MCP tool call.
|
||||
type ToolResult struct {
|
||||
Content []ContentBlock
|
||||
}
|
||||
|
||||
// ToolCaller abstracts MCP tool invocation so private overlays can call MCP
|
||||
// tools without importing internal packages. The open-source core provides a
|
||||
// concrete adapter wrapping executor.Runner.
|
||||
type ToolCaller interface {
|
||||
// CallTool invokes an MCP tool by product ID and tool name.
|
||||
CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*ToolResult, error)
|
||||
// Format returns the current output format ("json", "table", "raw").
|
||||
Format() string
|
||||
// DryRun returns true when --dry-run is active.
|
||||
DryRun() bool
|
||||
}
|
||||
|
||||
// Hooks groups all edition-specific behavioural overrides. Zero values
|
||||
// fall back to open-source defaults so the struct is safe to use as-is.
|
||||
type Hooks struct {
|
||||
// --- identity ---
|
||||
Name string // "open" (default) / overlay identifier
|
||||
ScenarioCode string // injected into x-dingtalk-scenario-code header
|
||||
|
||||
// --- runtime mode ---
|
||||
IsEmbedded bool // true when running inside a host application
|
||||
HideAuthLogin bool // true suppresses the "dws auth login" command
|
||||
AutoPurgeToken bool // true deletes local token data on expiry
|
||||
|
||||
// --- paths ---
|
||||
ConfigDir func() string // custom config directory; nil → ~/.dws
|
||||
|
||||
// --- HTTP headers ---
|
||||
MergeHeaders func(base map[string]string) map[string]string
|
||||
|
||||
// --- auth hooks ---
|
||||
OnAuthError func(configDir string, err error) error
|
||||
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
|
||||
|
||||
// --- product & endpoint ---
|
||||
StaticServers func() []ServerInfo // non-nil → skip Market discovery
|
||||
VisibleProducts func() []string // non-nil → override help visibility
|
||||
RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
current = defaultHooks()
|
||||
)
|
||||
|
||||
// Get returns the active edition hooks (never nil).
|
||||
func Get() *Hooks {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return current
|
||||
}
|
||||
|
||||
// Override replaces the active edition hooks. Must be called before Execute.
|
||||
func Override(h *Hooks) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
current = h
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package editiontest provides contract-test helpers that any edition.Hooks
|
||||
// implementation (open-source or overlay) must satisfy. It lives in a
|
||||
// non-_test package so external modules can import and run these checks.
|
||||
package editiontest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// RunContractTests validates that the supplied Hooks implementation
|
||||
// satisfies the behavioural contract required by the open-source core.
|
||||
// Call this from both the open-source test suite and any overlay's tests.
|
||||
func RunContractTests(t *testing.T, h *edition.Hooks) {
|
||||
t.Helper()
|
||||
|
||||
if h == nil {
|
||||
t.Fatal("Hooks must not be nil")
|
||||
}
|
||||
|
||||
t.Run("Name", func(t *testing.T) {
|
||||
if h.Name == "" {
|
||||
t.Error("Hooks.Name must be non-empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConfigDir", func(t *testing.T) {
|
||||
if h.ConfigDir != nil {
|
||||
dir := h.ConfigDir()
|
||||
if dir == "" {
|
||||
t.Error("ConfigDir() must return a non-empty path when set")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MergeHeaders_preserves_base", func(t *testing.T) {
|
||||
if h.MergeHeaders == nil {
|
||||
return
|
||||
}
|
||||
base := map[string]string{"x-test": "value"}
|
||||
merged := h.MergeHeaders(base)
|
||||
if merged == nil {
|
||||
t.Fatal("MergeHeaders must return a non-nil map")
|
||||
}
|
||||
if merged["x-test"] != "value" {
|
||||
t.Error("MergeHeaders must preserve base headers")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StaticServers_non_empty_when_set", func(t *testing.T) {
|
||||
if h.StaticServers == nil {
|
||||
return
|
||||
}
|
||||
servers := h.StaticServers()
|
||||
if len(servers) == 0 {
|
||||
t.Error("StaticServers() must return at least one server when set")
|
||||
}
|
||||
for i, s := range servers {
|
||||
if s.ID == "" || s.Endpoint == "" {
|
||||
t.Errorf("StaticServers()[%d]: ID and Endpoint must be non-empty", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("VisibleProducts_non_empty_when_set", func(t *testing.T) {
|
||||
if h.VisibleProducts == nil {
|
||||
return
|
||||
}
|
||||
products := h.VisibleProducts()
|
||||
if len(products) == 0 {
|
||||
t.Error("VisibleProducts() must return at least one product when set")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package editiontest_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/editiontest"
|
||||
)
|
||||
|
||||
func TestOpenSourceDefaultHooksContract(t *testing.T) {
|
||||
editiontest.RunContractTests(t, edition.Get())
|
||||
}
|
||||
@@ -30,6 +30,11 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
"WriteToken" + "Marker",
|
||||
"Token" + "Marker",
|
||||
"tokenJSON" + "File",
|
||||
"REWIND_" + "REQUEST_ID",
|
||||
"REWIND_" + "SESSION_ID",
|
||||
"REWIND_" + "MESSAGE_ID",
|
||||
"MergeWukong" + "MCPHeaders",
|
||||
"buildMode ==" + " \"real\"",
|
||||
}
|
||||
|
||||
var matches []string
|
||||
@@ -39,7 +44,7 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
|
||||
}
|
||||
if d.IsDir() {
|
||||
switch d.Name() {
|
||||
case ".git", ".worktrees", "node_modules", "dist":
|
||||
case ".git", ".worktrees", "node_modules", "dist", "plans":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user