Compare commits

..
28 Commits
Author SHA1 Message Date
fantiu 57c93243a0 Merge pull request #75 from fantiu/feat-login-upgrade
feat: add self-upgrade command with GitHub Releases, atomic flow, and cross-platform support
2026-04-02 17:00:42 +08:00
fantiu 4259336e6d style: gofmt formatting for upgrade files 2026-04-02 16:48:06 +08:00
fantiu a18ce2e54d feat: add self-upgrade command with GitHub Releases, atomic flow, and cross-platform support 2026-04-02 16:34:51 +08:00
fantiu a0dc5d6183 Merge branch 'main' of github.com:fantiu/dingtalk-workspace-cli into feat-login-upgrade
feat: add upgrade command.
2026-04-02 15:44:49 +08:00
fantiu 5149f6808f Merge branch 'DingTalk-Real-AI:main' into main 2026-04-02 15:43:07 +08:00
meng93 918db33a8b Merge pull request #74 from DingTalk-Real-AI/feat/issue-label
feat/issue label
2026-04-02 14:54:08 +08:00
meng93 95cbde9187 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/issue-label 2026-04-02 14:48:42 +08:00
meng93 5e194393ff feat: to #73551688 支持上报用例集标签 2026-04-02 14:48:02 +08:00
fantiu 3da572a76a Merge remote-tracking branch 'origin/main' into feat-login-upgrade 2026-04-02 11:47:58 +08:00
github-actions[bot] a116cba8ba chore: update coverage badge [skip ci] 2026-04-02 03:32:07 +00:00
fantiu fe5952fe14 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-02 11:30:30 +08:00
coffeeBigSir 2ab45ffd90 Merge pull request #72 from fantiu/feat-check-enable
feat(auth): switch CLI auth check to fail-closed with unified retry
2026-04-02 11:27:59 +08:00
fantiu 7c4932154c fix:auth check test file gofmt. 2026-04-02 11:20:06 +08:00
fantiu e2dbaa7c78 feat(auth): unify MCP retry constant and add retry to remaining endpoints 2026-04-02 11:10:49 +08:00
fantiu 11a0dbc84a feat(auth): unify MCP retry constant and add retry to remaining endpoints 2026-04-02 10:55:51 +08:00
fantiu ee441643dd fix(auth): switch CLI auth check from fail-open to fail-closed
Previously, when the /cli/cliAuthEnabled API was unreachable (network
error, timeout, 5xx, etc.), both OAuth and Device Flow login modes
silently assumed CLI access was enabled (fail-open). This allowed users
to "successfully" log in even when their organization had not granted
CLI data access, leading to confusing failures on subsequent API calls.

Changes:
- Reverse the check logic in OAuth callback: treat any error as
  "not enabled" instead of "enabled", showing the permission request
  page so users can take action.
- Block login in Device Flow when the check fails, with a clear
  error message asking users to verify network connectivity.
- Add retry with backoff (3 attempts, 0s/1s/2s) to
  CheckCLIAuthEnabled to tolerate transient network issues.
- Add retry with backoff (3 attempts) to FetchClientIDFromMCP
  (/cli/clientId) for the same transient-error resilience.
- Add i18n entries (en/zh) for new error messages.
- Add 19 tests covering server error, connection refused, malformed
  JSON, timeout, transient-then-recovery, business error, and full
  Device Flow loginOnce integration scenarios for both endpoints.

Made-with: Cursor
2026-04-02 10:55:51 +08:00
github-actions[bot] 4c5affba99 chore: update coverage badge [skip ci] 2026-04-02 02:44:56 +00:00
fantiu c8148ef2cc Merge pull request #71 from wxianfeng/feature/wk_merge
Feature/wk merge
2026-04-02 10:22:49 +08:00
fantiu b89740bad6 feat: add self-upgrade command with GitHub Releases as data source 2026-04-01 21:26:01 +08:00
xianfeng wang 5c0d2b274c Merge branch 'DingTalk-Real-AI:main' into feature/wk_merge 2026-04-01 21:05:35 +08:00
fantiu 0489cd67c8 Merge pull request #70 from DingTalk-Real-AI/feat-css
style(auth): redesign OAuth authorization pages UI
2026-04-01 20:38:00 +08:00
fantiu 15d495e56e style(auth): redesign OAuth authorization pages UI 2026-04-01 20:21:05 +08:00
xianfeng wang c74f1eeb26 Merge branch 'DingTalk-Real-AI:main' into feature/wk_merge 2026-04-01 09:53:58 +08:00
wxianfeng 933615003c merge upstream main 2026-04-01 09:53:18 +08:00
wxianfeng 3c75c66d4d chore: group pkg imports per goimports
Move pkg/config next to other pkg/* imports after local internal packages.

Made-with: Cursor
2026-03-31 15:32:50 +08:00
wxianfeng 93d6fdb17e feat: edition layer for Wukong overlay; promote shared code to pkg/
Introduce a build-time edition hook so downstream overlays (e.g. Wukong)
can customize auth UX, config dir, static server list, visible products,
and extra root commands while keeping the default open-source behavior.

- Add pkg/edition (defaults) and pkg/editiontest contract tests; Makefile
  target edition-test; CI job edition-tests.
- Root: optional RegisterExtraCommands with tool caller adapter; inject
  static servers to skip market discovery when configured; deduplicate
  top-level commands so overlay wins; extend hideNonDirectRuntimeCommands
  with edition VisibleProducts and static commands (recovery/schema/mcp).
- Auth/config: gate login subcommand and auth login hints for embedded
  editions; optional token auto-purge; edition ConfigDir override.
- version: human-readable multi-line output plus JSON with edition,
  architecture, build, commit.
- Relocate internal/{config,convert,validate} to pkg/; add pkg/cli and
  pkg/cmdutil (flags/time helpers).
- CI: optional notify-downstream job (GitLab trigger via secrets) on main
  push; open_source_policy_test adjusted for new layout.

Secrets (repo settings): WUKONG_TRIGGER_TOKEN, WUKONG_TRIGGER_URL — optional;
if unset, downstream step is skipped.

Made-with: Cursor
2026-03-31 15:29:21 +08:00
xianfeng wang 110f887181 Merge branch 'DingTalk-Real-AI:main' into main 2026-03-30 15:10:33 +08:00
github-actions[bot] 25118d1ec7 chore: update coverage badge [skip ci] 2026-03-30 03:09:54 +00:00
75 changed files with 6830 additions and 333 deletions
+1 -1
View File
@@ -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

+38
View File
@@ -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
+2 -1
View File
@@ -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)
}
};
+2 -1
View File
@@ -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`
}
};
+1
View File
@@ -27,3 +27,4 @@ test/cli_compat/testdata/
credentials*
plans
_docs
dws.zip
+4 -1
View File
@@ -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
+36
View File
@@ -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
+36
View File
@@ -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
+16 -5
View File
@@ -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
},
}
+5 -1
View File
@@ -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()
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4 -4
View File
@@ -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())
}
}
+11 -1
View File
@@ -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
}
+17
View File
@@ -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
}
+104
View File
@@ -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 ""
}
+720
View File
@@ -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
}
+430
View File
@@ -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")
}
}
+24
View File
@@ -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 }
+840
View File
@@ -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)
}
+7 -6
View File
@@ -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(), "")
+1 -1
View File
@@ -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 (
+7 -3
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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 {
+5 -5
View File
@@ -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:
}
})
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+4 -34
View File
@@ -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 {
+15
View File
@@ -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"
}
+15
View File
@@ -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 授权状态...",
"🔐 登录钉钉": "🔐 登录钉钉"
}
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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.
+2 -46
View File
@@ -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]
}
+4 -4
View File
@@ -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)
}
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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 (
+185
View File
@@ -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
}
+174
View File
@@ -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)
}
}
}
+293
View File
@@ -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)
}
+468
View File
@@ -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
}
+249
View File
@@ -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
}
+252
View File
@@ -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])
}
}
+203
View File
@@ -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()
}
+264
View File
@@ -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)
}
}
+299
View File
@@ -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
}
+342
View File
@@ -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
}
+66
View File
@@ -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)
}
+85
View File
@@ -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")
}
}
+47
View File
@@ -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
}
+51
View File
@@ -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)
}
}
}
+30
View File
@@ -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()
}
+166
View File
@@ -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]
}
+153
View File
@@ -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
}
+67
View File
@@ -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
}
+23
View File
@@ -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",
}
}
+106
View File
@@ -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
}
+88
View File
@@ -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")
}
})
}
+25
View File
@@ -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())
}
+6 -1
View File
@@ -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