diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg
deleted file mode 100644
index c742bb27..00000000
--- a/.github/badges/coverage.svg
+++ /dev/null
@@ -1 +0,0 @@
-coverage: 52.8% coverage coverage 52.8% 52.8%
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0d2e7c57..638ad57d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/issue-to-AI-table.yml b/.github/workflows/issue-to-AI-table.yml
new file mode 100644
index 00000000..216905e8
--- /dev/null
+++ b/.github/workflows/issue-to-AI-table.yml
@@ -0,0 +1,53 @@
+# Issue 变更推送到 Webhook
+# 当有 Issue 变更时,发送指定格式的数据到 webhook
+name: 📤 Issue Webhook Notification
+
+on:
+ issues:
+ types: [opened, reopened, closed, edited, labeled, unlabeled]
+
+jobs:
+ notify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 📬 Send Issue to Webhook
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const webhook = process.env.ISSUE_WEBHOOK_URL;
+ if (!webhook) {
+ console.log('⚠️ ISSUE_WEBHOOK_URL not set, skipping notification');
+ return;
+ }
+
+ const payload = context.payload;
+ const issue = payload.issue;
+ const action = payload.action;
+
+ // 构建指定格式的数据
+ const webhookPayload = {
+ action: action,
+ issue: {
+ id: issue.id,
+ number: issue.number,
+ title: issue.title,
+ body: issue.body,
+ state: issue.state,
+ html_url: issue.html_url,
+ labels: (issue.labels || []).map(label => label.name)
+ }
+ };
+
+ const response = await fetch(webhook, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(webhookPayload)
+ });
+
+ if (response.ok) {
+ console.log('✅ Webhook notification sent successfully');
+ } else {
+ console.log('❌ Failed to send webhook notification:', response.status, response.statusText);
+ }
+ env:
+ ISSUE_WEBHOOK_URL: ${{ secrets.DINGTALK_AI_TABLE_WEBHOOK }}
diff --git a/.github/workflows/issue-to-dingtalk.yml b/.github/workflows/issue-to-dingtalk.yml
new file mode 100644
index 00000000..0bbef4b6
--- /dev/null
+++ b/.github/workflows/issue-to-dingtalk.yml
@@ -0,0 +1,50 @@
+# Issue 自动同步到钉钉群
+# 当有新 Issue 时,自动发送到钉钉群(不包括 comment)
+name: 🔔 Issue Notification to DingTalk
+
+on:
+ issues:
+ types: [opened, reopened, closed, labeled]
+
+jobs:
+ notify:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 📬 Send Issue to DingTalk
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const webhook = process.env.DINGTALK_WEBHOOK;
+ if (!webhook) {
+ console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
+ return;
+ }
+
+ const payload = context.payload;
+ const issue = payload.issue;
+ const action = payload.action;
+
+ // 构建消息标题和内容(确保包含关键字 "issue" 以支持 Custom Keywords 模式)
+ 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🏷️ **Labels**: ${labelsText}\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
+ }
+ };
+
+ await fetch(webhook, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(message)
+ });
+
+ console.log('✅ DingTalk notification sent');
+ env:
+ DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 68341d1d..cd06991a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,6 +37,14 @@ jobs:
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
+ env:
+ DWS_PACKAGE_VERSION: ${{ github.ref_name }}
+
+ - name: Upload dws-skills.zip to release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
- name: Setup Node.js
uses: actions/setup-node@v4
diff --git a/.gitignore b/.gitignore
index c4615d59..e58ba18f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,4 @@ test/cli_compat/testdata/
credentials*
plans
_docs
+dws.zip
diff --git a/Makefile b/Makefile
index 0b8e42dc..28a0d30d 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/README.md b/README.md
index 06d0371d..a6608849 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -19,7 +19,7 @@
> [!IMPORTANT]
-> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below.
+> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
>
>
@@ -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)
@@ -65,6 +66,12 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
Other install methods
+**npm** (requires Node.js (npm/npx)):
+
+```bash
+npm install -g dingtalk-workspace-cli
+```
+
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
@@ -85,80 +92,91 @@ cp dws ~/.local/bin/ # install to PATH
+## Upgrade
+
+dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
+
+```bash
+dws upgrade # interactive upgrade to latest version
+dws upgrade --check # check for new versions without installing
+dws upgrade --list # list all available versions
+dws upgrade --version v1.0.7 # upgrade to a specific version
+dws upgrade --rollback # rollback to the previous version
+dws upgrade -y # skip confirmation prompt
+```
+
+
+How it works
+
+The upgrade process follows a two-phase atomic flow to ensure consistency:
+
+1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
+2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
+
+A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
+
+| Flag | Description |
+|------|-------------|
+| `--check` | Check for updates without installing |
+| `--list` | List all available versions with changelogs |
+| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
+| `--rollback` | Rollback to the previous backed-up version |
+| `--force` | Force reinstall even if already on the latest version |
+| `--skip-skills` | Skip skill package update |
+| `-y` | Skip confirmation prompt |
+
+
+
## Getting Started
-### Step 1: Create a DingTalk Application
-
-Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**.
-
-
-View screenshot
-
-
-
-
-
-### Step 2: Configure Redirect URL
-
-Go to app settings → **Security Settings**. Add the following redirect URLs and save:
-
-```
-http://127.0.0.1,https://login.dingtalk.com
+```bash
+dws auth login # browser opens automatically
+dws auth login --device # for headless environments (Docker, SSH, CI)
```
-> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
+Select your organization and authorize. That's it.
+
+> If your organization hasn't enabled CLI access, you'll be prompted to send an access request to your admin. Once approved, re-run `dws auth login`.
-View screenshot
+Organization hasn't enabled CLI access?
+
+1. After selecting your organization, click "Apply Now" to notify the admin
+2. The admin receives a request card and can approve with one click
+3. Once approved, re-run `dws auth login`
+
-
+
+
-### Step 3: Publish the Application
-
-Click "App Release - Version Management & Release" to publish and go live.
-
-View screenshot
+Admin: Enable CLI access for your organization
+
+Go to [Developer Platform](https://open-dev.dingtalk.com) → "CLI Access Management" → Enable.
+
-
+
+
-### Step 4: Request Whitelist Access
+
+Custom App mode (CI/CD, ISV integration)
-Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
+For enterprise-managed scenarios, create your own DingTalk app:
-### Step 5: Authenticate
+1. [Open Platform Console](https://open-dev.dingtalk.com/fe/app#/corp/app) → Create App
+2. Security Settings → Add redirect URLs: `http://127.0.0.1,https://login.dingtalk.com`
+3. Publish the app
+4. Login:
```bash
dws auth login --client-id --client-secret
```
-Or via environment variables:
-
-```bash
-export DWS_CLIENT_ID=
-export DWS_CLIENT_SECRET=
-dws auth login
-```
-
-
-Credential Configuration Priority
-
-`client-id` and `client-secret` support multiple configuration methods with the following priority (highest to lowest):
-
-| Priority | Method | Description |
-|----------|--------|-------------|
-| 1 | CLI flags / Persisted config | `--client-id` / `--client-secret` command-line arguments; auto-saved after first successful login with `client-secret` stored in system Keychain |
-| 2 | Environment variables | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
-| 3 | Default values | Hardcoded defaults (for development only) |
-
-**Recommended usage**:
-- **First login**: Use `--client-id` and `--client-secret` flags; credentials are securely persisted after successful login
-- **Subsequent use**: Run `dws` commands directly; token refresh automatically reads saved credentials from Keychain
-- **CI/CD environments**: Use environment variables
+Credentials are securely persisted after first login (Keychain). Subsequent runs auto-refresh tokens.
@@ -175,13 +193,6 @@ dws todo task list --dry-run # preview without executing
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
-```bash
-# Configure auth via environment variables (recommended for agents, no interactive login)
-export DWS_CLIENT_ID=
-export DWS_CLIENT_SECRET=
-dws auth login
-```
-
### Agent Invocation Patterns
```bash
@@ -334,22 +345,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## Key Services
-| Service | Command | Tools | Subcommands | Description |
-|---------|---------|:-----:|-------------|-------------|
-| Contact | `contact` | 8 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
-| Chat | `chat` | 14 | `message` `group` `bot` `search` | Group CRUD, member management, topic replies, send as user |
-| Bot | `chat bot` | 9 | — | Robot creation, group/single messaging, webhook, message recall |
+| Service | Command | Commands | Subcommands | Description |
+|---------|---------|:--------:|-------------|-------------|
+| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
+| Chat | `chat` | 10 | `message` `group` `search` | Group CRUD, member management, bot messaging, webhook |
+| Bot | `chat bot` | 6 | `bot` `group` `message` `search` | Robot creation/search, group/single messaging, webhook, message recall |
| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
-| Ding | `ding` | 3 | `message` | Send/recall DING messages |
+| Ding | `ding` | 2 | `message` | Send/recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
-| AITable | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, views, import/export, templates |
+| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
| Workbench | `workbench` | 2 | `app` | Batch query app details |
-| DevDoc | `devdoc` | 2 | `article` | Search platform docs and error codes |
+| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
-> 104 tools across 12 products. Run `dws --help` for the full list, or `dws --help` for subcommands.
+> 86 commands across 12 products. Run `dws --help` for the full list, or `dws --help` for subcommands.
Coming soon
diff --git a/README_zh.md b/README_zh.md
index 9b9c9b3a..b05c4905 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -9,7 +9,7 @@
-
+
@@ -19,7 +19,7 @@
> [!IMPORTANT]
-> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
+> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
>
@@ -28,6 +28,7 @@
- [为什么选择 dws?](#why-dws)
- [安装](#安装)
+- [升级](#升级)
- [开始使用](#开始使用)
- [快速开始](#快速开始)
- [在 Agent 中使用](#在-agent-中使用)
@@ -65,6 +66,12 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
其他安装方式
+**npm**(需要 Node.js(npm/npx)):
+
+```bash
+npm install -g dingtalk-workspace-cli
+```
+
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
@@ -85,80 +92,91 @@ cp dws ~/.local/bin/ # 安装到 PATH
+## 升级
+
+dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
+
+```bash
+dws upgrade # 交互式升级到最新版本
+dws upgrade --check # 仅检查是否有新版本
+dws upgrade --list # 列出所有可用版本
+dws upgrade --version v1.0.7 # 升级到指定版本
+dws upgrade --rollback # 回滚到上一版本
+dws upgrade -y # 跳过确认直接升级
+```
+
+
+工作原理
+
+升级过程采用两阶段原子流程,确保一致性:
+
+1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
+2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
+
+每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
+
+| Flag | 说明 |
+|------|------|
+| `--check` | 仅检查更新,不安装 |
+| `--list` | 列出所有可用版本及更新日志 |
+| `--version` | 升级到指定版本(如 `v1.0.7`) |
+| `--rollback` | 回滚到上一个备份版本 |
+| `--force` | 强制重新安装,即使已是最新版本 |
+| `--skip-skills` | 跳过技能包更新 |
+| `-y` | 跳过确认提示 |
+
+
+
## 开始使用
-### 步骤 1:创建钉钉应用
-
-进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。
-
-
-查看截图
-
-
-
-
-
-### 步骤 2:配置重定向 URL
-
-进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
-
-```
-http://127.0.0.1,https://login.dingtalk.com
+```bash
+dws auth login # 自动唤起浏览器
+dws auth login --device # 无浏览器环境(Docker、SSH、CI)
```
-> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
+选择组织并授权即可。
+
+> 如果组织尚未开启 CLI 访问权限,系统会引导你向管理员发送申请。审批通过后重新执行 `dws auth login` 即可。
-查看截图
+组织未开启 CLI 访问权限?
+
+1. 选择组织后,点击「立即申请」通知管理员
+2. 管理员收到申请卡片,一键审批
+3. 审批通过后,重新执行 `dws auth login`
+
-
+
+
-### 步骤 3:发布应用
-
-点击「应用发布 - 版本管理与发布」,发布版本使应用上线。
-
-查看截图
+管理员:为组织开启 CLI 访问权限
+
+进入 [开发者平台](https://open-dev.dingtalk.com) →「CLI 访问管理」→ 开启。
+
-
+
+
-### 步骤 4:申请白名单
+
+自建应用模式(CI/CD、ISV 集成)
-加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。
+企业自主管控场景,可创建自有钉钉应用:
-### 步骤 5:登录认证
+1. [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app#/corp/app) → 创建应用
+2. 安全设置 → 添加重定向 URL:`http://127.0.0.1,https://login.dingtalk.com`
+3. 发布应用
+4. 登录:
```bash
dws auth login --client-id --client-secret
```
-或通过环境变量:
-
-```bash
-export DWS_CLIENT_ID=
-export DWS_CLIENT_SECRET=
-dws auth login
-```
-
-
-凭证配置优先级
-
-`client-id` 和 `client-secret` 支持多种配置方式,按以下优先级生效(从高到低):
-
-| 优先级 | 配置方式 | 说明 |
-|------|----------|------|
-| 1 | CLI 参数 / 持久化配置 | `--client-id` / `--client-secret` 命令行参数;首次登录成功后自动保存,`client-secret` 存储在系统 Keychain 中 |
-| 2 | 环境变量 | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
-| 3 | 默认值 | 代码中的预设值(仅开发时使用) |
-
-**推荐用法**:
-- **首次登录**:使用 `--client-id` 和 `--client-secret` 参数,登录成功后凭证会自动安全存储
-- **后续使用**:直接运行 `dws` 命令,Token 自动刷新时会从 Keychain 读取已保存的凭证
-- **CI/CD 环境**:建议使用环境变量配置
+首次登录后凭证安全存储(Keychain),后续自动刷新 Token。
@@ -175,13 +193,6 @@ dws todo task list --dry-run # 预览操作但不执行
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
-```bash
-# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录)
-export DWS_CLIENT_ID=
-export DWS_CLIENT_SECRET=
-dws auth login
-```
-
### Agent 调用模式
```bash
@@ -334,22 +345,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 核心服务
-| 服务 | 命令 | 工具数 | 子命令 | 描述 |
+| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
-| 通讯录 | `contact` | 8 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
-| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、话题回复、以用户身份发消息 |
-| 机器人 | `chat bot` | 9 | — | 机器人创建、群聊/单聊消息、Webhook、消息撤回 |
+| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
+| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
+| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
-| DING | `ding` | 3 | `message` | 发送/撤回 DING 消息 |
+| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
-| 智能表格 | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、视图、导入导出、模板 |
+| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
-| 开发者文档 | `devdoc` | 2 | `article` | 搜索开放平台文档与错误码 |
+| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
-> 12 个产品,104 个工具。运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。
+> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。
即将推出
diff --git a/go.mod b/go.mod
index 2a734851..d3fd55e5 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.25.8
require (
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
+ github.com/itchyny/gojq v0.12.18
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
@@ -15,7 +16,6 @@ require (
require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
- github.com/itchyny/gojq v0.12.18 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go
index ce263247..bfaf4445 100644
--- a/internal/app/auth_command.go
+++ b/internal/app/auth_command.go
@@ -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(),
@@ -185,9 +188,19 @@ func newAuthLogoutCommand() *cobra.Command {
defer cancel()
_ = authpkg.RevokeTokenRemote(revokeCtx)
+ // Load token data to get associated clientId before deletion
+ var storedClientID string
+ if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
+ storedClientID = tokenData.ClientID
+ }
+
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
+ // Clean up associated client secret from keychain
+ if storedClientID != "" {
+ _ = authpkg.DeleteClientSecret(storedClientID)
+ }
// Clean up app credentials (app.json + keychain secret)
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
@@ -196,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
},
}
@@ -226,6 +241,8 @@ func newAuthStatusCommand() *cobra.Command {
tokenData = updatedData
refreshed = true
}
+ } else if edition.Get().AutoPurgeToken {
+ _ = authpkg.DeleteTokenData(configDir)
}
}
if authStatusAuthenticated(tokenData) {
@@ -253,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
},
@@ -331,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
},
}
diff --git a/internal/app/config.go b/internal/app/config.go
index 12015c95..68f3ae0a 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -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()
diff --git a/internal/app/help_source_test.go b/internal/app/help_source_test.go
index f8fb9614..5f9b9fe4 100644
--- a/internal/app/help_source_test.go
+++ b/internal/app/help_source_test.go
@@ -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")
}
}
diff --git a/internal/app/legacy.go b/internal/app/legacy.go
index 25fe605a..7de9fcd4 100644
--- a/internal/app/legacy.go
+++ b/internal/app/legacy.go
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
+ "fmt"
"log/slog"
"net"
"net/http"
@@ -29,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...)
}
@@ -46,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;
@@ -59,6 +89,12 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
+ totalStart := time.Now()
+ defer func() {
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart))
+ }
+ }()
store := cacheStoreFromEnv()
partition := config.DefaultPartition
@@ -70,13 +106,20 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
// --- Cache-first server registry ---
+ cacheLoadStart := time.Now()
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr)
+ }
+
var servers []market.ServerDescriptor
now := store.Now().UTC()
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
if usingCachedRegistry {
- slog.Debug("loadDynamicCommands: using cached registry", "servers", len(snapshot.Servers), "freshness", freshness)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness)
+ }
servers = snapshot.Servers
// Only trigger async revalidation in production (no URL override).
// Tests set discoveryBaseURLOverride and control cache expiry directly,
@@ -92,9 +135,15 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
if discoveryBaseURLOverride != "" {
baseURL = discoveryBaseURLOverride
}
- slog.Debug("loadDynamicCommands: fetching servers from market API", "base_url", baseURL)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL)
+ }
+ fetchStart := time.Now()
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr)
+ }
if fetchErr != nil {
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
@@ -106,12 +155,18 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
}
} else {
servers = market.NormalizeServers(resp, "market")
- slog.Debug("loadDynamicCommands: normalized servers", "count", len(servers))
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers))
+ }
// Persist fresh data (only in non-test mode).
if useCache {
+ saveStart := time.Now()
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
}
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart))
+ }
}
}
}
@@ -122,9 +177,17 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
+ detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart))
+ }
+
+ buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
- slog.Debug("loadDynamicCommands: built dynamic commands", "commands", len(cmds))
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds))
+ }
return cmds
}
diff --git a/internal/app/recovery_command_test.go b/internal/app/recovery_command_test.go
index 32fa6cf7..dd317b78 100644
--- a/internal/app/recovery_command_test.go
+++ b/internal/app/recovery_command_test.go
@@ -261,7 +261,7 @@ func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
- os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`}
+ os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
diff --git a/internal/app/root.go b/internal/app/root.go
index 99f0d372..f5c7e79f 100644
--- a/internal/app/root.go
+++ b/internal/app/root.go
@@ -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"
)
@@ -51,19 +52,43 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
// Execute runs the root command and returns the process exit code.
func Execute() int {
+ totalStart := time.Now()
+ timing := NewTimingCollector()
+ defer func() {
+ timing.PrintIfEnabled()
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart))
+ }
+ }()
+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
+ // Attach timing collector to context for use by child components
+ ctx = WithTimingCollector(ctx, timing)
+
+ initStart := time.Now()
recovery.ResetRuntimeState()
engine := newPipelineEngine()
root := NewRootCommandWithEngine(ctx, engine)
+ initDuration := time.Since(initStart)
+ timing.Record("cmd_init", initDuration)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", initDuration)
+ }
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
pipeline.RunPreParse(root, engine)
+ execStart := time.Now()
executed, err := root.ExecuteC()
+ execDuration := time.Since(execStart)
+ timing.Record("cobra_exec", execDuration)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", execDuration)
+ }
if err != nil {
if executed == nil {
executed = root
@@ -121,14 +146,14 @@ func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err erro
}
// resolveVerbosity derives the error verbosity level from the root command's flags.
-func resolveVerbosity(root *cobra.Command) apperrors.Verbosity {
- if root == nil {
+func resolveVerbosity(cmd *cobra.Command) apperrors.Verbosity {
+ if cmd == nil {
return apperrors.VerbosityNormal
}
- if debug, err := root.PersistentFlags().GetBool("debug"); err == nil && debug {
+ if debug, err := cmd.Flags().GetBool("debug"); err == nil && debug {
return apperrors.VerbosityDebug
}
- if verbose, err := root.PersistentFlags().GetBool("verbose"); err == nil && verbose {
+ if verbose, err := cmd.Flags().GetBool("verbose"); err == nil && verbose {
return apperrors.VerbosityVerbose
}
return apperrors.VerbosityNormal
@@ -250,9 +275,11 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(),
+ newSkillCommand(),
newCacheCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
+ newUpgradeCommand(),
newVersionCommand(),
schemaCmd,
genSkillsCmd,
@@ -262,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
@@ -275,6 +308,10 @@ func newAuthCommand() *cobra.Command {
return buildAuthCommand()
}
+func newSkillCommand() *cobra.Command {
+ return buildSkillCommand()
+}
+
func newCacheCommand() *cobra.Command {
cacheCmd := newPlaceholderParent("cache", "缓存管理")
@@ -436,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
},
}
}
@@ -550,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()
@@ -577,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)
diff --git a/internal/app/root_execute_test.go b/internal/app/root_execute_test.go
index 6a3294c4..178f31f4 100644
--- a/internal/app/root_execute_test.go
+++ b/internal/app/root_execute_test.go
@@ -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())
}
}
diff --git a/internal/app/root_help.go b/internal/app/root_help.go
index 9dfe9dc7..f005d05a 100644
--- a/internal/app/root_help.go
+++ b/internal/app/root_help.go
@@ -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
}
diff --git a/internal/app/runner.go b/internal/app/runner.go
index 25ce2192..8c817805 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -22,6 +22,7 @@ import (
"net/http"
"os"
"strings"
+ "sync"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
@@ -30,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 (
@@ -74,6 +76,13 @@ type runtimeRunner struct {
}
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
+ totalStart := time.Now()
+ defer func() {
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart))
+ }
+ }()
+
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -93,7 +102,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
}
}
+ catalogStart := time.Now()
catalog, err := r.loader.Load(ctx)
+ RecordTiming(ctx, "catalog_load", time.Since(catalogStart))
if err != nil {
return executor.Result{}, err
}
@@ -117,7 +128,19 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
}
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
- tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
+ // Lazy bind FileLogger: it may be nil at construction time because
+ // configureLogLevel runs later in PersistentPreRunE.
+ if r.transport.FileLogger == nil {
+ r.transport.FileLogger = FileLoggerInstance()
+ }
+
+ authStart := time.Now()
+ authToken := r.resolveAuthToken(ctx)
+ authDuration := time.Since(authStart)
+ RecordTiming(ctx, "auth_token", authDuration)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", authDuration)
+ }
if invocation.DryRun {
return executor.Result{
@@ -148,14 +171,39 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}, nil
}
+ // Fail-fast: reject unauthenticated requests before making network calls.
+ // This provides a clear error message instead of cryptic HTTP 400 from MCP.
+ if strings.TrimSpace(authToken) == "" {
+ return executor.Result{}, apperrors.NewAuth(
+ "未登录,请先执行 dws auth login",
+ apperrors.WithReason("not_authenticated"),
+ apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
+ apperrors.WithActions("dws auth login"),
+ )
+ }
+
+ tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
+
+ callStart := time.Now()
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
+ callDuration := time.Since(callStart)
+ RecordTiming(ctx, "mcp_call", callDuration)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = 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
}
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
+ logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
apperrors.WithOperation("tools/call"),
@@ -175,6 +223,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
+ logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
@@ -207,25 +256,58 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
if token := strings.TrimSpace(explicitToken); token != "" {
return token
}
- configDir := defaultConfigDir()
- provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
- configureOAuthProviderCompatibility(provider, configDir)
- token, tokenErr := provider.GetAccessToken(ctx)
- if tokenErr == nil && strings.TrimSpace(token) != "" {
- return strings.TrimSpace(token)
- }
- // If the error is a decryption failure (corrupted data), surface
- // it immediately instead of falling back to empty token.
- if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
- slog.Error(tokenErr.Error())
- return ""
- }
- manager := authpkg.NewManager(configDir, nil)
- configureLegacyAuthManagerCompatibility(manager)
- if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
- return strings.TrimSpace(token)
- }
- return ""
+ // Use cached token to avoid repeated Keychain access (~70ms per call)
+ return getCachedRuntimeToken(ctx)
+}
+
+// Cached token state for process lifetime
+var (
+ cachedRuntimeToken string
+ cachedRuntimeTokenOnce sync.Once
+)
+
+// getCachedRuntimeToken returns a cached access token, loading it only once per process.
+// This avoids repeated Keychain access which takes ~70ms each time.
+func getCachedRuntimeToken(ctx context.Context) string {
+ cachedRuntimeTokenOnce.Do(func() {
+ loadStart := time.Now()
+ defer func() {
+ loadDuration := time.Since(loadStart)
+ RecordTiming(ctx, "keychain_load", loadDuration)
+ if os.Getenv("DWS_PERF_DEBUG") != "" {
+ _, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", loadDuration)
+ }
+ }()
+
+ configDir := defaultConfigDir()
+ provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ configureOAuthProviderCompatibility(provider, configDir)
+ token, tokenErr := provider.GetAccessToken(ctx)
+ if tokenErr == nil && strings.TrimSpace(token) != "" {
+ cachedRuntimeToken = strings.TrimSpace(token)
+ return
+ }
+ // If the error is a decryption failure (corrupted data), log and bail out
+ if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
+ slog.Error(tokenErr.Error())
+ return
+ }
+ // Try legacy manager as fallback
+ manager := authpkg.NewManager(configDir, nil)
+ configureLegacyAuthManagerCompatibility(manager)
+ if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" {
+ cachedRuntimeToken = strings.TrimSpace(token)
+ return
+ }
+ })
+ return cachedRuntimeToken
+}
+
+// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
+// This should be called after login/logout operations.
+func ResetRuntimeTokenCache() {
+ cachedRuntimeTokenOnce = sync.Once{}
+ cachedRuntimeToken = ""
}
func newRuntimeContentScanner() safety.Scanner {
@@ -259,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))
@@ -289,6 +379,9 @@ func resolveIdentityHeaders() map[string]string {
headers[k] = v
}
}
+ if fn := edition.Get().MergeHeaders; fn != nil {
+ headers = fn(headers)
+ }
return headers
}
@@ -332,3 +425,36 @@ func extractMCPErrorMessage(result transport.ToolCallResult) string {
}
return "MCP tool returned an error response"
}
+
+// logBusinessError logs MCP tool errors and business errors to the file logger
+// so they can be diagnosed offline. These errors arrive as HTTP 200 responses
+// and would otherwise not be captured by transport-level logging.
+func logBusinessError(logger *slog.Logger, reason string, inv executor.Invocation, content map[string]any, diag apperrors.ServerDiagnostics) {
+ if logger == nil {
+ return
+ }
+ attrs := []any{
+ "product", inv.CanonicalProduct,
+ "tool", inv.Tool,
+ "reason", reason,
+ }
+ if diag.TraceID != "" {
+ attrs = append(attrs, "trace_id", diag.TraceID)
+ }
+ if diag.ServerErrorCode != "" {
+ attrs = append(attrs, "server_error_code", diag.ServerErrorCode)
+ }
+ if diag.TechnicalDetail != "" {
+ attrs = append(attrs, "technical_detail", diag.TechnicalDetail)
+ }
+ if msg, ok := content["error"].(string); ok {
+ attrs = append(attrs, "error", msg)
+ }
+ if msg, ok := content["errorMsg"].(string); ok {
+ attrs = append(attrs, "errorMsg", msg)
+ }
+ if msg, ok := content["message"].(string); ok {
+ attrs = append(attrs, "message", msg)
+ }
+ logger.Warn("business_error", attrs...)
+}
diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go
index 8434084b..d197a102 100644
--- a/internal/app/runner_test.go
+++ b/internal/app/runner_test.go
@@ -45,7 +45,7 @@ func TestRuntimeRunnerIncludesContentScanReportWhenEnabled(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
- cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
+ cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -90,7 +90,7 @@ func TestRuntimeRunnerBlocksUnsafeContentWhenEnforced(t *testing.T) {
cmd := NewRootCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
+ cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
err := cmd.Execute()
if err == nil {
@@ -121,7 +121,7 @@ func TestCanonicalCommandUsesRuntimeRunnerWhenEnabled(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
- cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes"})
+ cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes", "--token", "test-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -255,6 +255,37 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
}
}
+// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
+// a valid token are rejected with a clear error before making any network call.
+func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
+ setupRuntimeCommandTest(t)
+ server := mockmcp.DefaultServer()
+ defer server.Close()
+
+ t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
+
+ cmd := NewRootCommand()
+ var stdout, stderr bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stderr)
+ // No --token flag, should be rejected
+ cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() error = nil, want authentication error")
+ }
+
+ // Verify we get a clear auth error, not a cryptic HTTP 400
+ errMsg := err.Error()
+ if !strings.Contains(errMsg, "未登录") {
+ t.Fatalf("Execute() error = %v, want error containing '未登录'", err)
+ }
+ if !strings.Contains(errMsg, "auth login") {
+ t.Fatalf("Execute() error = %v, want error containing 'auth login'", err)
+ }
+}
+
func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
setupRuntimeCommandTest(t)
server := mockmcp.DefaultServer()
@@ -415,7 +446,7 @@ func TestCanonicalSensitiveToolAcceptsInteractiveConfirmation(t *testing.T) {
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetIn(strings.NewReader("yes\n"))
- cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`})
+ cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--token", "test-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -465,7 +496,7 @@ func TestRuntimeRunnerUsesProductEndpointOverride(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
- cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
+ cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -628,7 +659,7 @@ func TestRuntimeRunnerReturnsErrorWhenMCPIsErrorTrue(t *testing.T) {
cmd := NewRootCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
- cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
+ cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
err := cmd.Execute()
if err == nil {
diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go
new file mode 100644
index 00000000..4a20cbb5
--- /dev/null
+++ b/internal/app/skill_command.go
@@ -0,0 +1,377 @@
+// 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 (
+ "archive/zip"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/spf13/cobra"
+)
+
+const (
+ // skillDownloadEndpoint is the API endpoint for downloading skills.
+ skillDownloadEndpoint = "https://aihub.dingtalk.com/cli/download"
+ // skillDownloadTimeout is the timeout for skill download operations.
+ skillDownloadTimeout = 5 * time.Minute
+)
+
+// downloadSkillResponse represents the API response for skill download.
+type downloadSkillResponse struct {
+ Success bool `json:"success"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ Result *downloadSkillResult `json:"result,omitempty"`
+}
+
+// downloadSkillResult contains the download URL and file name.
+type downloadSkillResult struct {
+ DownloadURL string `json:"downloadUrl"`
+ FileName string `json:"fileName"`
+}
+
+// agentSkillPaths maps target names to their relative skill installation paths.
+// These paths are relative to the user's home directory.
+var agentSkillPaths = map[string]string{
+ "qoder": ".qoder/skills",
+ "claude": ".claude/skills",
+ "cursor": ".cursor/skills",
+ "codex": ".codex/skills",
+ "opencode": filepath.Join(".config", "opencode", "skills"),
+}
+
+// supportedTargets returns a comma-separated list of supported targets.
+func supportedTargets() string {
+ targets := make([]string, 0, len(agentSkillPaths)+1)
+ for target := range agentSkillPaths {
+ targets = append(targets, target)
+ }
+ targets = append(targets, ".")
+ return strings.Join(targets, ", ")
+}
+
+func buildSkillCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "skill",
+ Short: "技能管理",
+ Long: "管理钉钉技能市场的技能。支持下载和安装技能到指定 Agent 目录。",
+ Args: cobra.NoArgs,
+ TraverseChildren: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return cmd.Help()
+ },
+ }
+
+ cmd.AddCommand(newSkillAddCommand())
+ return cmd
+}
+
+func newSkillAddCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "add ",
+ Short: "下载并安装技能到指定目录",
+ Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
+
+参数:
+ skillId 技能 ID(必填),可从钉钉技能市场获取
+ target 安装目标(必填),支持: %s
+
+安装路径:
+ qoder -> ~/.qoder/skills/
+ claude -> ~/.claude/skills/
+ cursor -> ~/.cursor/skills/
+ codex -> ~/.codex/skills/
+ opencode -> ~/.config/opencode/skills/
+ . -> 当前目录
+
+示例:
+ dws skill add skill-123 qoder # 安装到 ~/.qoder/skills/
+ dws skill add skill-123 claude # 安装到 ~/.claude/skills/
+ dws skill add skill-123 . # 安装到当前目录`, supportedTargets()),
+ Args: cobra.ExactArgs(2),
+ DisableAutoGenTag: true,
+ RunE: runSkillAdd,
+ }
+
+ return cmd
+}
+
+func runSkillAdd(cmd *cobra.Command, args []string) error {
+ skillID := strings.TrimSpace(args[0])
+ target := strings.TrimSpace(args[1])
+
+ if skillID == "" {
+ return apperrors.NewValidation("skillId is required")
+ }
+
+ // Resolve target path
+ destPath, err := resolveSkillTargetPath(target)
+ if err != nil {
+ return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
+ }
+
+ // Load auth token
+ configDir := defaultConfigDir()
+ tokenData, err := authpkg.LoadTokenData(configDir)
+ if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
+ return apperrors.NewAuth("not logged in or token expired. Please run 'dws auth login' first",
+ apperrors.WithHint("请先执行 'dws auth login' 登录"),
+ apperrors.WithActions("dws auth login"))
+ }
+
+ ctx, cancel := context.WithTimeout(cmd.Context(), skillDownloadTimeout)
+ defer cancel()
+
+ w := cmd.OutOrStdout()
+
+ // Step 1: Get download URL from API
+ fmt.Fprintf(w, "正在获取技能信息...\n")
+ downloadResp, err := fetchSkillDownloadInfo(ctx, tokenData.AccessToken, skillID)
+ if err != nil {
+ return err
+ }
+
+ if !downloadResp.Success {
+ errMsg := downloadResp.ErrorMsg
+ if errMsg == "" {
+ errMsg = downloadResp.ErrorCode
+ }
+ if errMsg == "" {
+ errMsg = "unknown error"
+ }
+ return apperrors.NewAPI(fmt.Sprintf("failed to get skill download info: %s", errMsg),
+ apperrors.WithReason(downloadResp.ErrorCode))
+ }
+
+ if downloadResp.Result == nil || downloadResp.Result.DownloadURL == "" {
+ return apperrors.NewAPI("skill download URL not found in response")
+ }
+
+ // Step 2: Download the skill zip file
+ fmt.Fprintf(w, "正在下载技能...\n")
+ tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
+ if err != nil {
+ return err
+ }
+ defer cleanupTempFile(tempZipPath)
+
+ // Step 3: Extract zip to destination
+ fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
+ if err := extractSkillZip(tempZipPath, destPath); err != nil {
+ return err
+ }
+
+ fmt.Fprintf(w, "\n[OK] 技能安装成功!\n")
+ fmt.Fprintf(w, "安装路径: %s\n", destPath)
+
+ return nil
+}
+
+// resolveSkillTargetPath resolves the target argument to an absolute path.
+func resolveSkillTargetPath(target string) (string, error) {
+ target = strings.TrimSpace(target)
+ if target == "" {
+ return "", fmt.Errorf("target is required")
+ }
+
+ // Special case: current directory
+ if target == "." {
+ return os.Getwd()
+ }
+
+ // Look up predefined agent paths
+ relPath, ok := agentSkillPaths[strings.ToLower(target)]
+ if !ok {
+ return "", fmt.Errorf("unsupported target")
+ }
+
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get home directory: %w", err)
+ }
+
+ return filepath.Join(homeDir, relPath), nil
+}
+
+// fetchSkillDownloadInfo calls the download API to get the skill download URL.
+func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
+ url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("x-user-access-token", accessToken)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
+ apperrors.WithRetryable(true))
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return nil, apperrors.NewAuth("authentication failed. Please run 'dws auth login' to refresh your token",
+ apperrors.WithHint("请执行 'dws auth login' 重新登录"),
+ apperrors.WithActions("dws auth login"))
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, apperrors.NewAPI(fmt.Sprintf("download API returned HTTP %d", resp.StatusCode),
+ apperrors.WithRetryable(resp.StatusCode >= 500))
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
+ if err != nil {
+ return nil, apperrors.NewAPI(fmt.Sprintf("failed to read response: %v", err))
+ }
+
+ var result downloadSkillResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, apperrors.NewAPI(fmt.Sprintf("failed to parse response: %v", err))
+ }
+
+ return &result, nil
+}
+
+// downloadSkillFile downloads the skill zip file to a temporary location.
+func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
+ if err != nil {
+ return "", apperrors.NewInternal(fmt.Sprintf("failed to create download request: %v", err))
+ }
+
+ client := &http.Client{Timeout: skillDownloadTimeout}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
+ apperrors.WithRetryable(true))
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", apperrors.NewAPI(fmt.Sprintf("download returned HTTP %d", resp.StatusCode),
+ apperrors.WithRetryable(resp.StatusCode >= 500))
+ }
+
+ // Create temp file
+ if fileName == "" {
+ fileName = "skill.zip"
+ }
+ tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
+ if err != nil {
+ return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
+ }
+ tempPath := tempFile.Name()
+
+ // Copy response body to temp file
+ _, err = io.Copy(tempFile, resp.Body)
+ closeErr := tempFile.Close()
+ if err != nil {
+ os.Remove(tempPath)
+ return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
+ }
+ if closeErr != nil {
+ os.Remove(tempPath)
+ return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
+ }
+
+ return tempPath, nil
+}
+
+// extractSkillZip extracts a zip file to the destination directory.
+func extractSkillZip(zipPath, destDir string) error {
+ // Ensure destination directory exists
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
+ }
+
+ reader, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return apperrors.NewInternal(fmt.Sprintf("failed to open zip file: %v", err))
+ }
+ defer reader.Close()
+
+ for _, file := range reader.File {
+ if err := extractZipFile(file, destDir); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// extractZipFile extracts a single file from the zip archive.
+func extractZipFile(file *zip.File, destDir string) error {
+ // Sanitize file path to prevent zip slip attacks
+ filePath := filepath.Join(destDir, file.Name)
+ if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean(destDir)+string(os.PathSeparator)) {
+ return apperrors.NewValidation(fmt.Sprintf("invalid file path in zip: %s", file.Name))
+ }
+
+ if file.FileInfo().IsDir() {
+ // Use 0755 to ensure we have write permission for creating files inside
+ return os.MkdirAll(filePath, 0755)
+ }
+
+ // Ensure parent directory exists with write permission
+ if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
+ return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
+ }
+
+ // Extract file
+ srcFile, err := file.Open()
+ if err != nil {
+ return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
+ }
+ defer srcFile.Close()
+
+ // Use file mode from zip but ensure at least 0644 for files
+ fileMode := file.Mode()
+ if fileMode&0600 == 0 {
+ fileMode = 0644
+ }
+ destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
+ if err != nil {
+ return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
+ }
+ defer destFile.Close()
+
+ if _, err := io.Copy(destFile, srcFile); err != nil {
+ return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
+ }
+
+ return nil
+}
+
+// cleanupTempFile removes a temporary file, ignoring errors.
+func cleanupTempFile(path string) {
+ if path != "" {
+ os.Remove(path)
+ }
+}
diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go
new file mode 100644
index 00000000..354c010c
--- /dev/null
+++ b/internal/app/skill_command_test.go
@@ -0,0 +1,732 @@
+// 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 (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
+)
+
+func TestResolveSkillTargetPath(t *testing.T) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ t.Fatalf("failed to get home directory: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ target string
+ wantSuffix string
+ wantErr bool
+ }{
+ {
+ name: "qoder target",
+ target: "qoder",
+ wantSuffix: filepath.Join(".qoder", "skills"),
+ wantErr: false,
+ },
+ {
+ name: "claude target",
+ target: "claude",
+ wantSuffix: filepath.Join(".claude", "skills"),
+ wantErr: false,
+ },
+ {
+ name: "cursor target",
+ target: "cursor",
+ wantSuffix: filepath.Join(".cursor", "skills"),
+ wantErr: false,
+ },
+ {
+ name: "codex target",
+ target: "codex",
+ wantSuffix: filepath.Join(".codex", "skills"),
+ wantErr: false,
+ },
+ {
+ name: "opencode target",
+ target: "opencode",
+ wantSuffix: filepath.Join(".config", "opencode", "skills"),
+ wantErr: false,
+ },
+ {
+ name: "case insensitive - QODER",
+ target: "QODER",
+ wantSuffix: filepath.Join(".qoder", "skills"),
+ wantErr: false,
+ },
+ {
+ name: "case insensitive - Claude",
+ target: "Claude",
+ wantSuffix: filepath.Join(".claude", "skills"),
+ wantErr: false,
+ },
+ {
+ name: "invalid target",
+ target: "invalid",
+ wantErr: true,
+ },
+ {
+ name: "empty target",
+ target: "",
+ wantErr: true,
+ },
+ {
+ name: "whitespace only",
+ target: " ",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := resolveSkillTargetPath(tt.target)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("resolveSkillTargetPath() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr {
+ expected := filepath.Join(homeDir, tt.wantSuffix)
+ if got != expected {
+ t.Errorf("resolveSkillTargetPath() = %v, want %v", got, expected)
+ }
+ }
+ })
+ }
+}
+
+func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
+ // Test "." target returns current working directory
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("failed to get working directory: %v", err)
+ }
+
+ got, err := resolveSkillTargetPath(".")
+ if err != nil {
+ t.Errorf("resolveSkillTargetPath(\".\") error = %v", err)
+ return
+ }
+
+ if got != cwd {
+ t.Errorf("resolveSkillTargetPath(\".\") = %v, want %v", got, cwd)
+ }
+}
+
+func TestParseDownloadSkillResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ jsonInput string
+ wantSuccess bool
+ wantURL string
+ wantFile string
+ wantErrCode string
+ wantErrMsg string
+ }{
+ {
+ name: "successful response",
+ jsonInput: `{
+ "success": true,
+ "result": {
+ "downloadUrl": "https://example.com/skill.zip",
+ "fileName": "my-skill.zip"
+ }
+ }`,
+ wantSuccess: true,
+ wantURL: "https://example.com/skill.zip",
+ wantFile: "my-skill.zip",
+ },
+ {
+ name: "error response",
+ jsonInput: `{
+ "success": false,
+ "errorCode": "SKILL_NOT_FOUND",
+ "errorMsg": "The skill does not exist"
+ }`,
+ wantSuccess: false,
+ wantErrCode: "SKILL_NOT_FOUND",
+ wantErrMsg: "The skill does not exist",
+ },
+ {
+ name: "success with empty result",
+ jsonInput: `{
+ "success": true,
+ "result": null
+ }`,
+ wantSuccess: true,
+ wantURL: "",
+ wantFile: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var resp downloadSkillResponse
+ if err := json.Unmarshal([]byte(tt.jsonInput), &resp); err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ if resp.Success != tt.wantSuccess {
+ t.Errorf("Success = %v, want %v", resp.Success, tt.wantSuccess)
+ }
+
+ if tt.wantSuccess && resp.Result != nil {
+ if resp.Result.DownloadURL != tt.wantURL {
+ t.Errorf("DownloadURL = %v, want %v", resp.Result.DownloadURL, tt.wantURL)
+ }
+ if resp.Result.FileName != tt.wantFile {
+ t.Errorf("FileName = %v, want %v", resp.Result.FileName, tt.wantFile)
+ }
+ }
+
+ if !tt.wantSuccess {
+ if resp.ErrorCode != tt.wantErrCode {
+ t.Errorf("ErrorCode = %v, want %v", resp.ErrorCode, tt.wantErrCode)
+ }
+ if resp.ErrorMsg != tt.wantErrMsg {
+ t.Errorf("ErrorMsg = %v, want %v", resp.ErrorMsg, tt.wantErrMsg)
+ }
+ }
+ })
+ }
+}
+
+func TestExtractSkillZip(t *testing.T) {
+ // Create a temporary zip file with test content
+ tempDir := t.TempDir()
+ zipPath := filepath.Join(tempDir, "test.zip")
+ destDir := filepath.Join(tempDir, "extracted")
+
+ // Create zip file with test content
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatalf("failed to create zip file: %v", err)
+ }
+
+ zipWriter := zip.NewWriter(zipFile)
+
+ // Add a file to the zip
+ fileContent := []byte("test content")
+ writer, err := zipWriter.Create("test-file.txt")
+ if err != nil {
+ t.Fatalf("failed to create file in zip: %v", err)
+ }
+ if _, err := writer.Write(fileContent); err != nil {
+ t.Fatalf("failed to write file content: %v", err)
+ }
+
+ // Add a subdirectory with a file
+ writer, err = zipWriter.Create("subdir/nested-file.txt")
+ if err != nil {
+ t.Fatalf("failed to create nested file in zip: %v", err)
+ }
+ if _, err := writer.Write([]byte("nested content")); err != nil {
+ t.Fatalf("failed to write nested file content: %v", err)
+ }
+
+ if err := zipWriter.Close(); err != nil {
+ t.Fatalf("failed to close zip writer: %v", err)
+ }
+ if err := zipFile.Close(); err != nil {
+ t.Fatalf("failed to close zip file: %v", err)
+ }
+
+ // Extract the zip
+ if err := extractSkillZip(zipPath, destDir); err != nil {
+ t.Fatalf("extractSkillZip() error = %v", err)
+ }
+
+ // Verify extracted files
+ extractedFile := filepath.Join(destDir, "test-file.txt")
+ content, err := os.ReadFile(extractedFile)
+ if err != nil {
+ t.Errorf("failed to read extracted file: %v", err)
+ }
+ if string(content) != "test content" {
+ t.Errorf("extracted content = %v, want %v", string(content), "test content")
+ }
+
+ // Verify nested file
+ nestedFile := filepath.Join(destDir, "subdir", "nested-file.txt")
+ content, err = os.ReadFile(nestedFile)
+ if err != nil {
+ t.Errorf("failed to read nested file: %v", err)
+ }
+ if string(content) != "nested content" {
+ t.Errorf("nested content = %v, want %v", string(content), "nested content")
+ }
+}
+
+func TestExtractSkillZipPreventZipSlip(t *testing.T) {
+ tempDir := t.TempDir()
+ zipPath := filepath.Join(tempDir, "malicious.zip")
+ destDir := filepath.Join(tempDir, "extracted")
+
+ // Create a zip file with a path traversal attempt
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatalf("failed to create zip file: %v", err)
+ }
+
+ zipWriter := zip.NewWriter(zipFile)
+
+ // Try to create a file with path traversal
+ writer, err := zipWriter.Create("../../../etc/passwd")
+ if err != nil {
+ t.Fatalf("failed to create malicious file in zip: %v", err)
+ }
+ if _, err := writer.Write([]byte("malicious content")); err != nil {
+ t.Fatalf("failed to write malicious content: %v", err)
+ }
+
+ if err := zipWriter.Close(); err != nil {
+ t.Fatalf("failed to close zip writer: %v", err)
+ }
+ if err := zipFile.Close(); err != nil {
+ t.Fatalf("failed to close zip file: %v", err)
+ }
+
+ // Extract should fail due to zip slip protection
+ err = extractSkillZip(zipPath, destDir)
+ if err == nil {
+ t.Error("extractSkillZip() should have failed for zip slip attack")
+ }
+ if !strings.Contains(err.Error(), "invalid file path") {
+ t.Errorf("error should mention invalid file path, got: %v", err)
+ }
+}
+
+func TestSkillAddCommandValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "missing arguments",
+ args: []string{"skill", "add"},
+ wantErr: true,
+ errMsg: "accepts 2 arg(s)",
+ },
+ {
+ name: "missing target",
+ args: []string{"skill", "add", "skill-123"},
+ wantErr: true,
+ errMsg: "accepts 2 arg(s)",
+ },
+ {
+ name: "too many arguments",
+ args: []string{"skill", "add", "skill-123", "qoder", "extra"},
+ wantErr: true,
+ errMsg: "accepts 2 arg(s)",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := NewRootCommand()
+ cmd.SetArgs(tt.args)
+
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+
+ err := cmd.Execute()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if tt.wantErr && !strings.Contains(err.Error(), tt.errMsg) {
+ t.Errorf("error = %v, should contain %v", err, tt.errMsg)
+ }
+ })
+ }
+}
+
+func TestSkillAddInvalidTarget(t *testing.T) {
+ // Setup: Create config directory with valid token
+ tempDir := t.TempDir()
+ configDir := filepath.Join(tempDir, "config")
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+
+ // Save a valid token
+ err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
+ AccessToken: "test-token",
+ RefreshToken: "refresh-token",
+ ExpiresAt: time.Now().Add(time.Hour),
+ RefreshExpAt: time.Now().Add(24 * time.Hour),
+ })
+ if err != nil {
+ t.Fatalf("failed to save token data: %v", err)
+ }
+
+ cmd := NewRootCommand()
+ cmd.SetArgs([]string{"skill", "add", "skill-123", "invalid-target"})
+
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+
+ err = cmd.Execute()
+ if err == nil {
+ t.Error("Execute() should have failed for invalid target")
+ }
+ if !strings.Contains(err.Error(), "invalid target") {
+ t.Errorf("error should mention invalid target, got: %v", err)
+ }
+}
+
+func TestSkillAddRequiresAuth(t *testing.T) {
+ // Setup: Create config directory without token
+ tempDir := t.TempDir()
+ configDir := filepath.Join(tempDir, "config")
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+
+ // Ensure the config directory exists but has no token
+ if err := os.MkdirAll(configDir, 0755); err != nil {
+ t.Fatalf("failed to create config dir: %v", err)
+ }
+
+ cmd := NewRootCommand()
+ cmd.SetArgs([]string{"skill", "add", "skill-123", "qoder"})
+
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Error("Execute() should have failed without auth")
+ }
+ // Check for authentication-related error (English or Chinese)
+ errStr := err.Error()
+ if !strings.Contains(errStr, "not logged in") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "未登录") && !strings.Contains(errStr, "auth") {
+ t.Errorf("error should mention authentication, got: %v", err)
+ }
+}
+
+func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
+ // Create mock server that returns 401
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer server.Close()
+
+ // We can't easily test the actual fetchSkillDownloadInfo function
+ // because it uses a hardcoded URL. This test verifies HTTP 401 handling pattern.
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Get(server.URL)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("expected 401, got %d", resp.StatusCode)
+ }
+}
+
+func TestSupportedTargets(t *testing.T) {
+ targets := supportedTargets()
+
+ // Should contain all predefined targets
+ expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."}
+ for _, expected := range expectedTargets {
+ if !strings.Contains(targets, expected) {
+ t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets)
+ }
+ }
+}
+
+func TestAgentSkillPathsCrossPlatform(t *testing.T) {
+ // Verify that paths use platform-appropriate separators
+ for target, path := range agentSkillPaths {
+ if runtime.GOOS == "windows" {
+ if strings.Contains(path, "/") && !strings.Contains(path, "\\") {
+ // On Windows, filepath.Join should use backslashes
+ // But raw map values may use forward slashes
+ t.Logf("Note: %s path '%s' uses forward slashes (will be converted by filepath.Join)", target, path)
+ }
+ }
+
+ // Test that resolveSkillTargetPath produces valid paths
+ resolved, err := resolveSkillTargetPath(target)
+ if err != nil {
+ t.Errorf("resolveSkillTargetPath(%s) failed: %v", target, err)
+ continue
+ }
+
+ // Path should be absolute
+ if !filepath.IsAbs(resolved) {
+ t.Errorf("resolveSkillTargetPath(%s) returned non-absolute path: %s", target, resolved)
+ }
+ }
+}
+
+func TestCleanupTempFile(t *testing.T) {
+ // Create a temporary file
+ tempFile, err := os.CreateTemp("", "test-cleanup-*.txt")
+ if err != nil {
+ t.Fatalf("failed to create temp file: %v", err)
+ }
+ tempPath := tempFile.Name()
+ tempFile.Close()
+
+ // Verify file exists
+ if _, err := os.Stat(tempPath); os.IsNotExist(err) {
+ t.Fatalf("temp file should exist before cleanup")
+ }
+
+ // Clean up
+ cleanupTempFile(tempPath)
+
+ // Verify file is deleted
+ if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
+ t.Errorf("temp file should be deleted after cleanup")
+ }
+
+ // Cleanup should not panic on empty path
+ cleanupTempFile("")
+
+ // Cleanup should not panic on non-existent file
+ cleanupTempFile("/nonexistent/path/file.txt")
+}
+
+func TestDownloadSkillResponseJSON(t *testing.T) {
+ // Test JSON marshaling/unmarshaling round-trip
+ original := downloadSkillResponse{
+ Success: true,
+ Result: &downloadSkillResult{
+ DownloadURL: "https://example.com/skill.zip",
+ FileName: "skill.zip",
+ },
+ }
+
+ data, err := json.Marshal(original)
+ if err != nil {
+ t.Fatalf("failed to marshal: %v", err)
+ }
+
+ var parsed downloadSkillResponse
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("failed to unmarshal: %v", err)
+ }
+
+ if parsed.Success != original.Success {
+ t.Errorf("Success mismatch: got %v, want %v", parsed.Success, original.Success)
+ }
+ if parsed.Result.DownloadURL != original.Result.DownloadURL {
+ t.Errorf("DownloadURL mismatch: got %v, want %v", parsed.Result.DownloadURL, original.Result.DownloadURL)
+ }
+ if parsed.Result.FileName != original.Result.FileName {
+ t.Errorf("FileName mismatch: got %v, want %v", parsed.Result.FileName, original.Result.FileName)
+ }
+}
+
+func TestSkillCommandHelp(t *testing.T) {
+ cmd := NewRootCommand()
+ cmd.SetArgs([]string{"skill", "--help"})
+
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ output := out.String()
+ // Check for the Long description which is shown in help
+ if !strings.Contains(output, "技能") {
+ t.Errorf("help should mention '技能', got: %s", output)
+ }
+ if !strings.Contains(output, "add") {
+ t.Errorf("help should mention 'add' subcommand, got: %s", output)
+ }
+}
+
+func TestSkillAddCommandHelp(t *testing.T) {
+ cmd := NewRootCommand()
+ cmd.SetArgs([]string{"skill", "add", "--help"})
+
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ output := out.String()
+ // Should mention supported targets
+ expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode"}
+ for _, target := range expectedTargets {
+ if !strings.Contains(output, target) {
+ t.Errorf("help should mention target '%s', got: %s", target, output)
+ }
+ }
+}
+
+func TestDownloadSkillFileSuccess(t *testing.T) {
+ // Create a mock server that returns a zip file
+ expectedContent := []byte("fake zip content")
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/zip")
+ w.WriteHeader(http.StatusOK)
+ w.Write(expectedContent)
+ }))
+ defer server.Close()
+
+ // Download the file
+ ctx := context.Background()
+ tempPath, err := downloadSkillFile(ctx, server.URL, "test.zip")
+ if err != nil {
+ t.Fatalf("downloadSkillFile() error = %v", err)
+ }
+ defer os.Remove(tempPath)
+
+ // Verify the downloaded content
+ content, err := os.ReadFile(tempPath)
+ if err != nil {
+ t.Fatalf("failed to read downloaded file: %v", err)
+ }
+
+ if !bytes.Equal(content, expectedContent) {
+ t.Errorf("downloaded content mismatch: got %v, want %v", content, expectedContent)
+ }
+}
+
+func TestDownloadSkillFileServerError(t *testing.T) {
+ // Create a mock server that returns 500
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ ctx := context.Background()
+ _, err := downloadSkillFile(ctx, server.URL, "test.zip")
+ if err == nil {
+ t.Error("downloadSkillFile() should fail on server error")
+ }
+}
+
+func TestExtractSkillZipEmptyZip(t *testing.T) {
+ tempDir := t.TempDir()
+ zipPath := filepath.Join(tempDir, "empty.zip")
+ destDir := filepath.Join(tempDir, "extracted")
+
+ // Create an empty zip file
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatalf("failed to create zip file: %v", err)
+ }
+ zipWriter := zip.NewWriter(zipFile)
+ if err := zipWriter.Close(); err != nil {
+ t.Fatalf("failed to close zip writer: %v", err)
+ }
+ if err := zipFile.Close(); err != nil {
+ t.Fatalf("failed to close zip file: %v", err)
+ }
+
+ // Extract should succeed even for empty zip
+ if err := extractSkillZip(zipPath, destDir); err != nil {
+ t.Errorf("extractSkillZip() should not fail for empty zip: %v", err)
+ }
+
+ // Destination directory should be created
+ if _, err := os.Stat(destDir); os.IsNotExist(err) {
+ t.Errorf("destination directory should be created")
+ }
+}
+
+func TestExtractSkillZipWithDirectories(t *testing.T) {
+ tempDir := t.TempDir()
+ zipPath := filepath.Join(tempDir, "test.zip")
+ destDir := filepath.Join(tempDir, "extracted")
+
+ // Create zip with directory entries
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatalf("failed to create zip file: %v", err)
+ }
+
+ zipWriter := zip.NewWriter(zipFile)
+
+ // Add a directory entry with proper permissions
+ header := &zip.FileHeader{
+ Name: "mydir/",
+ Method: zip.Deflate,
+ }
+ header.SetMode(0755 | os.ModeDir)
+ _, err = zipWriter.CreateHeader(header)
+ if err != nil {
+ t.Fatalf("failed to create directory in zip: %v", err)
+ }
+
+ // Add a file in the directory
+ fileHeader := &zip.FileHeader{
+ Name: "mydir/file.txt",
+ Method: zip.Deflate,
+ }
+ fileHeader.SetMode(0644)
+ writer, err := zipWriter.CreateHeader(fileHeader)
+ if err != nil {
+ t.Fatalf("failed to create file in zip: %v", err)
+ }
+ if _, err := writer.Write([]byte("content")); err != nil {
+ t.Fatalf("failed to write content: %v", err)
+ }
+
+ if err := zipWriter.Close(); err != nil {
+ t.Fatalf("failed to close zip writer: %v", err)
+ }
+ if err := zipFile.Close(); err != nil {
+ t.Fatalf("failed to close zip file: %v", err)
+ }
+
+ // Extract
+ if err := extractSkillZip(zipPath, destDir); err != nil {
+ t.Fatalf("extractSkillZip() error = %v", err)
+ }
+
+ // Verify directory was created
+ dirPath := filepath.Join(destDir, "mydir")
+ info, err := os.Stat(dirPath)
+ if err != nil {
+ t.Errorf("directory should exist: %v", err)
+ } else if !info.IsDir() {
+ t.Errorf("mydir should be a directory")
+ }
+
+ // Verify file exists
+ filePath := filepath.Join(destDir, "mydir", "file.txt")
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ t.Errorf("file should exist: %v", err)
+ } else if string(content) != "content" {
+ t.Errorf("file content mismatch: got %s, want 'content'", string(content))
+ }
+}
diff --git a/internal/app/timing.go b/internal/app/timing.go
new file mode 100644
index 00000000..9f3ff5e3
--- /dev/null
+++ b/internal/app/timing.go
@@ -0,0 +1,177 @@
+// 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"
+ "fmt"
+ "io"
+ "os"
+ "sort"
+ "sync"
+ "time"
+)
+
+// Environment variable to enable performance timing output.
+const PerfTimingEnv = "DWS_PERF_TIMING"
+
+// timingContextKey is the context key for TimingCollector.
+type timingContextKey struct{}
+
+// TimingEntry represents a single timing measurement.
+type TimingEntry struct {
+ Name string
+ Duration time.Duration
+ Timestamp time.Time
+ Seq int // insertion order
+}
+
+// TimingCollector collects timing measurements for a single command execution.
+// It is safe for concurrent use.
+type TimingCollector struct {
+ mu sync.Mutex
+ start time.Time
+ entries []TimingEntry
+ seq int
+}
+
+// NewTimingCollector creates a new collector with the start time set to now.
+func NewTimingCollector() *TimingCollector {
+ return &TimingCollector{
+ start: time.Now(),
+ entries: make([]TimingEntry, 0, 16),
+ }
+}
+
+// Record adds a timing entry with the given name and duration.
+func (tc *TimingCollector) Record(name string, d time.Duration) {
+ if tc == nil {
+ return
+ }
+ tc.mu.Lock()
+ defer tc.mu.Unlock()
+ tc.entries = append(tc.entries, TimingEntry{
+ Name: name,
+ Duration: d,
+ Timestamp: time.Now(),
+ Seq: tc.seq,
+ })
+ tc.seq++
+}
+
+// StartTimer returns a function that, when called, records the elapsed time
+// since StartTimer was called. This is convenient for defer usage:
+//
+// defer tc.StartTimer("operation")()
+func (tc *TimingCollector) StartTimer(name string) func() {
+ if tc == nil {
+ return func() {}
+ }
+ start := time.Now()
+ return func() {
+ tc.Record(name, time.Since(start))
+ }
+}
+
+// Total returns the total elapsed time since the collector was created.
+func (tc *TimingCollector) Total() time.Duration {
+ if tc == nil {
+ return 0
+ }
+ return time.Since(tc.start)
+}
+
+// Entries returns a copy of all recorded entries in insertion order.
+func (tc *TimingCollector) Entries() []TimingEntry {
+ if tc == nil {
+ return nil
+ }
+ tc.mu.Lock()
+ defer tc.mu.Unlock()
+ result := make([]TimingEntry, len(tc.entries))
+ copy(result, tc.entries)
+ sort.Slice(result, func(i, j int) bool {
+ return result[i].Seq < result[j].Seq
+ })
+ return result
+}
+
+// Print writes a summary of all timing entries to the given writer.
+func (tc *TimingCollector) Print(w io.Writer) {
+ if tc == nil || w == nil {
+ return
+ }
+ entries := tc.Entries()
+ if len(entries) == 0 {
+ fmt.Fprintf(w, "\n[Timing] Total: %v (no detailed entries)\n", tc.Total().Truncate(time.Millisecond))
+ return
+ }
+
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "[Timing] Execution breakdown:")
+ for _, e := range entries {
+ fmt.Fprintf(w, " %-30s %v\n", e.Name, e.Duration.Truncate(time.Millisecond))
+ }
+ fmt.Fprintf(w, " %-30s %v\n", "──────────────────────────────", "──────────")
+ fmt.Fprintf(w, " %-30s %v\n", "Total", tc.Total().Truncate(time.Millisecond))
+}
+
+// PrintIfEnabled prints timing info to stderr if DWS_PERF_TIMING is set.
+func (tc *TimingCollector) PrintIfEnabled() {
+ if tc == nil {
+ return
+ }
+ if os.Getenv(PerfTimingEnv) == "" {
+ return
+ }
+ tc.Print(os.Stderr)
+}
+
+// WithTimingCollector returns a new context with the TimingCollector attached.
+func WithTimingCollector(ctx context.Context, tc *TimingCollector) context.Context {
+ return context.WithValue(ctx, timingContextKey{}, tc)
+}
+
+// TimingCollectorFromContext extracts the TimingCollector from context, or nil.
+func TimingCollectorFromContext(ctx context.Context) *TimingCollector {
+ if ctx == nil {
+ return nil
+ }
+ tc, _ := ctx.Value(timingContextKey{}).(*TimingCollector)
+ return tc
+}
+
+// RecordTiming is a convenience function to record timing to the collector in context.
+func RecordTiming(ctx context.Context, name string, d time.Duration) {
+ if tc := TimingCollectorFromContext(ctx); tc != nil {
+ tc.Record(name, d)
+ }
+}
+
+// StartTiming is a convenience function that returns a stop function for defer usage.
+// Example:
+//
+// defer StartTiming(ctx, "operation")()
+func StartTiming(ctx context.Context, name string) func() {
+ tc := TimingCollectorFromContext(ctx)
+ if tc == nil {
+ return func() {}
+ }
+ return tc.StartTimer(name)
+}
+
+// IsPerfTimingEnabled returns true if performance timing output is enabled.
+func IsPerfTimingEnabled() bool {
+ return os.Getenv(PerfTimingEnv) != ""
+}
diff --git a/internal/app/timing_test.go b/internal/app/timing_test.go
new file mode 100644
index 00000000..f57fb9e3
--- /dev/null
+++ b/internal/app/timing_test.go
@@ -0,0 +1,173 @@
+// 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 (
+ "bytes"
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTimingCollector_Basic(t *testing.T) {
+ tc := NewTimingCollector()
+ if tc == nil {
+ t.Fatal("NewTimingCollector returned nil")
+ }
+
+ // Record some timings
+ tc.Record("op1", 10*time.Millisecond)
+ tc.Record("op2", 20*time.Millisecond)
+
+ entries := tc.Entries()
+ if len(entries) != 2 {
+ t.Errorf("expected 2 entries, got %d", len(entries))
+ }
+
+ // Check ordering
+ if entries[0].Name != "op1" {
+ t.Errorf("expected first entry to be 'op1', got %q", entries[0].Name)
+ }
+ if entries[1].Name != "op2" {
+ t.Errorf("expected second entry to be 'op2', got %q", entries[1].Name)
+ }
+}
+
+func TestTimingCollector_StartTimer(t *testing.T) {
+ tc := NewTimingCollector()
+
+ stop := tc.StartTimer("timed_op")
+ time.Sleep(5 * time.Millisecond)
+ stop()
+
+ entries := tc.Entries()
+ if len(entries) != 1 {
+ t.Fatalf("expected 1 entry, got %d", len(entries))
+ }
+ if entries[0].Name != "timed_op" {
+ t.Errorf("expected entry name 'timed_op', got %q", entries[0].Name)
+ }
+ if entries[0].Duration < 5*time.Millisecond {
+ t.Errorf("expected duration >= 5ms, got %v", entries[0].Duration)
+ }
+}
+
+func TestTimingCollector_NilSafe(t *testing.T) {
+ var tc *TimingCollector
+
+ // Should not panic on nil collector
+ tc.Record("op", 10*time.Millisecond)
+ stop := tc.StartTimer("op")
+ stop()
+ _ = tc.Total()
+ _ = tc.Entries()
+ tc.Print(nil)
+ tc.PrintIfEnabled()
+}
+
+func TestTimingCollector_Print(t *testing.T) {
+ tc := NewTimingCollector()
+ tc.Record("auth_token", 44*time.Millisecond)
+ tc.Record("mcp_call", 150*time.Millisecond)
+
+ var buf bytes.Buffer
+ tc.Print(&buf)
+
+ output := buf.String()
+ if !strings.Contains(output, "[Timing]") {
+ t.Error("output should contain [Timing] header")
+ }
+ if !strings.Contains(output, "auth_token") {
+ t.Error("output should contain 'auth_token'")
+ }
+ if !strings.Contains(output, "mcp_call") {
+ t.Error("output should contain 'mcp_call'")
+ }
+ if !strings.Contains(output, "Total") {
+ t.Error("output should contain 'Total'")
+ }
+}
+
+func TestTimingCollector_PrintIfEnabled(t *testing.T) {
+ // Set environment variable
+ os.Setenv(PerfTimingEnv, "1")
+ defer os.Unsetenv(PerfTimingEnv)
+
+ tc := NewTimingCollector()
+ tc.Record("test_op", 10*time.Millisecond)
+
+ // This should not panic and should print to stderr
+ tc.PrintIfEnabled()
+}
+
+func TestTimingCollector_ContextIntegration(t *testing.T) {
+ tc := NewTimingCollector()
+ ctx := WithTimingCollector(context.Background(), tc)
+
+ // Retrieve from context
+ retrieved := TimingCollectorFromContext(ctx)
+ if retrieved != tc {
+ t.Error("TimingCollectorFromContext should return the same collector")
+ }
+
+ // Use convenience functions
+ RecordTiming(ctx, "ctx_op", 30*time.Millisecond)
+ stop := StartTiming(ctx, "ctx_timed")
+ time.Sleep(2 * time.Millisecond)
+ stop()
+
+ entries := tc.Entries()
+ if len(entries) != 2 {
+ t.Errorf("expected 2 entries, got %d", len(entries))
+ }
+}
+
+func TestTimingCollectorFromContext_NilContext(t *testing.T) {
+ tc := TimingCollectorFromContext(nil)
+ if tc != nil {
+ t.Error("TimingCollectorFromContext(nil) should return nil")
+ }
+}
+
+func TestTimingCollectorFromContext_NoCollector(t *testing.T) {
+ tc := TimingCollectorFromContext(context.Background())
+ if tc != nil {
+ t.Error("TimingCollectorFromContext with no collector should return nil")
+ }
+}
+
+func TestStartTiming_NoCollector(t *testing.T) {
+ ctx := context.Background()
+ stop := StartTiming(ctx, "no_collector")
+ // Should not panic
+ stop()
+}
+
+func TestIsPerfTimingEnabled(t *testing.T) {
+ // Clear the env var first
+ os.Unsetenv(PerfTimingEnv)
+
+ if IsPerfTimingEnabled() {
+ t.Error("IsPerfTimingEnabled should return false when env var is not set")
+ }
+
+ os.Setenv(PerfTimingEnv, "1")
+ defer os.Unsetenv(PerfTimingEnv)
+
+ if !IsPerfTimingEnabled() {
+ t.Error("IsPerfTimingEnabled should return true when env var is set")
+ }
+}
diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go
new file mode 100644
index 00000000..95daf49f
--- /dev/null
+++ b/internal/app/tool_caller_adapter.go
@@ -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 ""
+}
diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go
new file mode 100644
index 00000000..ccee390b
--- /dev/null
+++ b/internal/app/upgrade.go
@@ -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
+}
diff --git a/internal/app/upgrade_test.go b/internal/app/upgrade_test.go
new file mode 100644
index 00000000..f1c6efa2
--- /dev/null
+++ b/internal/app/upgrade_test.go
@@ -0,0 +1,430 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package app
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// --- ensureV ---
+
+func TestEnsureV(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"1.0.6", "v1.0.6"},
+ {"v1.0.6", "v1.0.6"},
+ {"0.0.1", "v0.0.1"},
+ {"dev", "dev"},
+ {"unknown", "unknown"},
+ {"", "v0.0.0"},
+ {"v", "v"},
+ }
+ for _, tt := range tests {
+ got := ensureV(tt.in)
+ if got != tt.want {
+ t.Errorf("ensureV(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
+
+// --- parseChangelogEntries ---
+
+func TestParseChangelogEntries(t *testing.T) {
+ body := `## Changelog
+* abcdef1234567 - fix login bug
+* 0123456789abc Merge branch 'main' into main
+* fedcba9876543 - add upgrade command
+* deadbeef12345 Merge pull request #42
+* 1234567890abc - improve error handling
+`
+ entries := parseChangelogEntries(body, 10)
+
+ if len(entries) != 3 {
+ t.Fatalf("len(entries) = %d, want 3 (merge commits should be filtered)", len(entries))
+ }
+ if entries[0] != "fix login bug" {
+ t.Errorf("entries[0] = %q, want %q", entries[0], "fix login bug")
+ }
+ if entries[1] != "add upgrade command" {
+ t.Errorf("entries[1] = %q, want %q", entries[1], "add upgrade command")
+ }
+ if entries[2] != "improve error handling" {
+ t.Errorf("entries[2] = %q, want %q", entries[2], "improve error handling")
+ }
+}
+
+func TestParseChangelogEntries_MaxLimit(t *testing.T) {
+ body := "* abc1234 - msg1\n* def5678 - msg2\n* ghi9012 - msg3\n"
+ entries := parseChangelogEntries(body, 2)
+ if len(entries) != 2 {
+ t.Errorf("len = %d, want 2 (should respect maxEntries)", len(entries))
+ }
+}
+
+func TestParseChangelogEntries_EmptyBody(t *testing.T) {
+ entries := parseChangelogEntries("", 10)
+ if len(entries) != 0 {
+ t.Errorf("len = %d, want 0 for empty body", len(entries))
+ }
+}
+
+func TestParseChangelogEntries_OnlyHeaders(t *testing.T) {
+ body := "## Changelog\n## Another heading\n"
+ entries := parseChangelogEntries(body, 10)
+ if len(entries) != 0 {
+ t.Errorf("len = %d, want 0 for headers-only body", len(entries))
+ }
+}
+
+func TestParseChangelogEntries_OnlyMergeCommits(t *testing.T) {
+ body := "* abc1234 Merge branch 'main'\n* def5678 Merge pull request #10\n"
+ entries := parseChangelogEntries(body, 10)
+ if len(entries) != 0 {
+ t.Errorf("len = %d, want 0 (all merge commits should be filtered)", len(entries))
+ }
+}
+
+func TestParseChangelogEntries_DashPrefixedLines(t *testing.T) {
+ body := "- fix bug\n- add feature\n"
+ entries := parseChangelogEntries(body, 10)
+ if len(entries) != 2 {
+ t.Fatalf("len = %d, want 2", len(entries))
+ }
+ if entries[0] != "fix bug" {
+ t.Errorf("entries[0] = %q, want %q", entries[0], "fix bug")
+ }
+}
+
+// --- stripCommitHash ---
+
+func TestStripCommitHash(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"abcdef1234567 - fix bug", "fix bug"},
+ {"abcdef1234567 fix bug", "fix bug"},
+ {"short", "short"}, // too short to be a hash
+ {"abc123", "abc123"}, // less than 7 hex chars
+ {"no hash here", "no hash here"},
+ {"ABCDEF1234567 - upper case hash", "upper case hash"},
+ }
+ for _, tt := range tests {
+ got := stripCommitHash(tt.in)
+ if got != tt.want {
+ t.Errorf("stripCommitHash(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ }
+}
+
+// --- isNoiseCommit ---
+
+func TestIsNoiseCommit(t *testing.T) {
+ tests := []struct {
+ msg string
+ want bool
+ }{
+ {"Merge branch 'main'", true},
+ {"merge branch 'develop'", true},
+ {"Merge pull request #42", true},
+ {"Merge remote-tracking branch 'origin/main'", true},
+ {"fix login bug", false},
+ {"add new feature", false},
+ {"merge conflicts resolved", false}, // doesn't start with "merge branch"
+ }
+ for _, tt := range tests {
+ got := isNoiseCommit(tt.msg)
+ if got != tt.want {
+ t.Errorf("isNoiseCommit(%q) = %v, want %v", tt.msg, got, tt.want)
+ }
+ }
+}
+
+// --- truncateChangelog ---
+
+func TestTruncateChangelog(t *testing.T) {
+ body := "## Changelog\n* abc1234 - fix A\n* def5678 - fix B\n* ghi9012 - fix C\n* jkl3456 - fix D\n"
+ result := truncateChangelog(body)
+ if result == "" {
+ t.Error("truncateChangelog returned empty")
+ }
+ // Should contain max 3 entries separated by "; "
+ parts := strings.Split(result, "; ")
+ if len(parts) > 3 {
+ t.Errorf("truncateChangelog should have at most 3 entries, got %d", len(parts))
+ }
+}
+
+func TestTruncateChangelog_EmptyBody(t *testing.T) {
+ if got := truncateChangelog(""); got != "" {
+ t.Errorf("truncateChangelog('') = %q, want empty", got)
+ }
+}
+
+// --- truncateChangelogForList ---
+
+func TestTruncateChangelogForList(t *testing.T) {
+ tests := []struct {
+ body string
+ maxLen int
+ want string
+ }{
+ {"", 40, "-"},
+ {"## Changelog\n", 40, "-"},
+ }
+ for _, tt := range tests {
+ got := truncateChangelogForList(tt.body, tt.maxLen)
+ if got != tt.want {
+ t.Errorf("truncateChangelogForList(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
+ }
+ }
+}
+
+func TestTruncateChangelogForList_Truncation(t *testing.T) {
+ body := "* abc1234 - a very long commit message that should be truncated eventually\n"
+ result := truncateChangelogForList(body, 20)
+ if len(result) > 20 {
+ t.Errorf("result len = %d, want <= 20", len(result))
+ }
+ if !strings.HasSuffix(result, "...") {
+ t.Errorf("truncated result should end with '...' , got %q", result)
+ }
+}
+
+// --- progressBar ---
+
+func TestProgressBar(t *testing.T) {
+ tests := []struct {
+ percent float64
+ filled int
+ }{
+ {0, 0},
+ {50, 10},
+ {100, 20},
+ {150, 20}, // capped
+ }
+ for _, tt := range tests {
+ bar := progressBar(tt.percent)
+ if len(bar) != 20*len("█") && len(bar) != 20*len("░") {
+ // Since multi-byte chars, just check total rune count
+ runes := []rune(bar)
+ if len(runes) != 20 {
+ t.Errorf("progressBar(%v) rune count = %d, want 20", tt.percent, len(runes))
+ }
+ }
+ filledCount := strings.Count(bar, "█")
+ if filledCount != tt.filled {
+ t.Errorf("progressBar(%v) filled = %d, want %d", tt.percent, filledCount, tt.filled)
+ }
+ }
+}
+
+// --- shortenHome ---
+
+func TestShortenHome(t *testing.T) {
+ // Non-home path should be unchanged
+ got := shortenHome("/tmp/somewhere")
+ if got != "/tmp/somewhere" {
+ t.Errorf("shortenHome(/tmp/somewhere) = %q", got)
+ }
+}
+
+// --- resolveUpgradeFormat ---
+
+func TestResolveUpgradeFormat_Default(t *testing.T) {
+ root := &cobra.Command{}
+ root.PersistentFlags().String("format", "json", "output format")
+ child := &cobra.Command{}
+ root.AddCommand(child)
+
+ // format not changed => should default to "table" for upgrade
+ got := resolveUpgradeFormat(child)
+ if got != "table" {
+ t.Errorf("resolveUpgradeFormat(unchanged) = %q, want %q", got, "table")
+ }
+}
+
+func TestResolveUpgradeFormat_ExplicitJSON(t *testing.T) {
+ root := &cobra.Command{}
+ root.PersistentFlags().String("format", "json", "output format")
+ child := &cobra.Command{}
+ root.AddCommand(child)
+
+ // Simulate user explicitly setting format
+ root.PersistentFlags().Set("format", "json")
+
+ got := resolveUpgradeFormat(child)
+ if got != "json" {
+ t.Errorf("resolveUpgradeFormat(explicit json) = %q, want %q", got, "json")
+ }
+}
+
+func TestResolveUpgradeFormat_ExplicitTable(t *testing.T) {
+ root := &cobra.Command{}
+ root.PersistentFlags().String("format", "json", "output format")
+ child := &cobra.Command{}
+ root.AddCommand(child)
+
+ root.PersistentFlags().Set("format", "table")
+
+ got := resolveUpgradeFormat(child)
+ if got != "table" {
+ t.Errorf("resolveUpgradeFormat(explicit table) = %q, want %q", got, "table")
+ }
+}
+
+// --- writeJSON ---
+
+func TestWriteJSON(t *testing.T) {
+ var buf bytes.Buffer
+ data := map[string]any{
+ "version": "v1.0.6",
+ "ok": true,
+ }
+ if err := writeJSON(&buf, data); err != nil {
+ t.Fatalf("writeJSON() error = %v", err)
+ }
+ output := buf.String()
+ if !strings.Contains(output, `"version": "v1.0.6"`) {
+ t.Errorf("output missing version: %s", output)
+ }
+ if !strings.Contains(output, `"ok": true`) {
+ t.Errorf("output missing ok: %s", output)
+ }
+}
+
+// --- strictVerifyFile ---
+
+func TestStrictVerifyFile_MatchesChecksums(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "test.tar.gz")
+ content := []byte("valid binary content")
+ os.WriteFile(filePath, content, 0644)
+
+ hash := computeTestSHA256(t, content)
+ checksums := hash + " test.tar.gz\n"
+
+ err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
+ if err != nil {
+ t.Errorf("expected success, got %v", err)
+ }
+}
+
+func TestStrictVerifyFile_ChecksumMismatch(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "test.tar.gz")
+ os.WriteFile(filePath, []byte("tampered content"), 0644)
+
+ checksums := "0000000000000000000000000000000000000000000000000000000000000000 test.tar.gz\n"
+
+ err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", checksums)
+ if err == nil {
+ t.Fatal("expected error for checksum mismatch")
+ }
+ if !strings.Contains(err.Error(), "校验失败") {
+ t.Errorf("error = %q, want to contain '校验失败'", err.Error())
+ }
+}
+
+func TestStrictVerifyFile_DigestMismatch(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "test.tar.gz")
+ os.WriteFile(filePath, []byte("tampered"), 0644)
+
+ err := strictVerifyFile("[1/5]", filePath, "test.tar.gz",
+ "sha256:0000000000000000000000000000000000000000000000000000000000000000",
+ "")
+ if err == nil {
+ t.Fatal("expected error for digest mismatch")
+ }
+}
+
+func TestStrictVerifyFile_NoChecksumInfo(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "test.tar.gz")
+ os.WriteFile(filePath, []byte("content"), 0644)
+
+ err := strictVerifyFile("[1/5]", filePath, "test.tar.gz", "", "")
+ if err != nil {
+ t.Errorf("no checksum info should skip, not error: %v", err)
+ }
+}
+
+func TestStrictVerifyFile_FileNotInChecksums_FallsToDigest(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "skills.zip")
+ content := []byte("skills content")
+ os.WriteFile(filePath, content, 0644)
+
+ hash := computeTestSHA256(t, content)
+ // checksums.txt has entries but NOT skills.zip
+ checksums := "abcdef1234567890 other-file.tar.gz\n"
+
+ err := strictVerifyFile("[1/5]", filePath, "skills.zip", "sha256:"+hash, checksums)
+ if err != nil {
+ t.Errorf("should fall through to digest and succeed: %v", err)
+ }
+}
+
+func computeTestSHA256(t *testing.T, data []byte) string {
+ t.Helper()
+ h := sha256.Sum256(data)
+ return hex.EncodeToString(h[:])
+}
+
+// --- newUpgradeCommand ---
+
+func TestNewUpgradeCommand_Flags(t *testing.T) {
+ cmd := newUpgradeCommand()
+
+ if cmd.Use != "upgrade" {
+ t.Errorf("Use = %q, want upgrade", cmd.Use)
+ }
+
+ expectedFlags := []string{"check", "list", "version", "rollback", "force", "skip-skills"}
+ for _, name := range expectedFlags {
+ if cmd.Flags().Lookup(name) == nil {
+ t.Errorf("missing flag: --%s", name)
+ }
+ }
+}
+
+func TestNewUpgradeCommand_NoArgs(t *testing.T) {
+ cmd := newUpgradeCommand()
+ // Simulate passing positional args - should error with cobra.NoArgs
+ cmd.SetArgs([]string{"rollback"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Error("expected error for positional args (NoArgs)")
+ }
+}
+
+func TestNewUpgradeCommand_Help(t *testing.T) {
+ cmd := newUpgradeCommand()
+ var buf bytes.Buffer
+ cmd.SetOut(&buf)
+ cmd.SetArgs([]string{"--help"})
+ cmd.Execute()
+ help := buf.String()
+
+ if !strings.Contains(help, "upgrade") {
+ t.Error("help should contain 'upgrade'")
+ }
+ if !strings.Contains(help, "--check") {
+ t.Error("help should contain --check")
+ }
+ if !strings.Contains(help, "--rollback") {
+ t.Error("help should contain --rollback")
+ }
+}
diff --git a/internal/app/version.go b/internal/app/version.go
index 2942c201..f93c9ebc 100644
--- a/internal/app/version.go
+++ b/internal/app/version.go
@@ -13,7 +13,22 @@
package app
-var version = "v1.0.2"
+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).
@@ -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 }
diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go
index 0f8fb940..2eb5b682 100644
--- a/internal/auth/auth_extra_test.go
+++ b/internal/auth/auth_extra_test.go
@@ -26,6 +26,7 @@ func TestClientID_RuntimeOverride(t *testing.T) {
func TestClientID_EnvFallback(t *testing.T) {
SetClientID("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "env-id")
if got := ClientID(); got != "env-id" {
t.Fatalf("expected env-id, got %s", got)
@@ -34,9 +35,17 @@ func TestClientID_EnvFallback(t *testing.T) {
func TestClientID_Default(t *testing.T) {
SetClientID("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_ID", "")
- if got := ClientID(); got != DefaultClientID {
- t.Fatalf("expected default, got %s", got)
+ // When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string
+ if strings.HasPrefix(DefaultClientID, "<") {
+ if got := ClientID(); got != "" {
+ t.Fatalf("expected empty string for placeholder, got %s", got)
+ }
+ } else {
+ if got := ClientID(); got != DefaultClientID {
+ t.Fatalf("expected default, got %s", got)
+ }
}
}
@@ -51,6 +60,7 @@ func TestClientSecret_RuntimeOverride(t *testing.T) {
func TestClientSecret_EnvFallback(t *testing.T) {
SetClientSecret("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "env-secret")
if got := ClientSecret(); got != "env-secret" {
t.Fatalf("expected env-secret, got %s", got)
@@ -59,6 +69,7 @@ func TestClientSecret_EnvFallback(t *testing.T) {
func TestClientSecret_Default(t *testing.T) {
SetClientSecret("")
+ t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config
t.Setenv("DWS_CLIENT_SECRET", "")
if got := ClientSecret(); got != DefaultClientSecret {
t.Fatalf("expected default, got %s", got)
diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go
new file mode 100644
index 00000000..ca076908
--- /dev/null
+++ b/internal/auth/cli_auth_check_test.go
@@ -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)
+}
diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go
index 94f8cc9c..8b7a0d6c 100644
--- a/internal/auth/device_flow.go
+++ b/internal/auth/device_flow.go
@@ -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"
)
@@ -96,6 +96,23 @@ type serviceResult struct {
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
+ // Ensure we have a valid client ID (fetch from MCP if not available)
+ if p.clientID == "" {
+ if p.logger != nil {
+ p.logger.Debug("client ID not configured, fetching from MCP server")
+ }
+ mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
+ if mcpErr != nil {
+ return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
+ }
+ p.clientID = mcpClientID
+ // Mark that clientID is from MCP
+ SetClientIDFromMCP(mcpClientID)
+ if p.logger != nil {
+ p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
+ }
+ }
+
const maxAttempts = 3
for attempt := 1; attempt <= maxAttempts; attempt++ {
tokenData, err := p.loginOnce(ctx, attempt)
@@ -150,6 +167,44 @@ 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 (fail-closed: block on error)
+ dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0)
+ authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
+ if authErr != nil {
+ _, _ = 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(), "")
+ _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限")))
+ _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
+ _, _ = fmt.Fprintln(p.output(), "")
+
+ // Try to get super admin list
+ admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken)
+ if adminErr == nil && admins.Success && len(admins.Result) > 0 {
+ // Show up to 3 admins
+ maxAdmins := 3
+ if len(admins.Result) < maxAdmins {
+ maxAdmins = len(admins.Result)
+ }
+ var adminNames []string
+ for i := 0; i < maxAdmins; i++ {
+ adminNames = append(adminNames, admins.Result[i].Name)
+ }
+ _, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、"))
+ }
+
+ _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。"))
+ _, _ = fmt.Fprintln(p.output(), "")
+ _, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings"))
+ _, _ = fmt.Fprintln(p.output(), "")
+ return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启"))
+ }
+
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go
index 446cec57..d56201f4 100644
--- a/internal/auth/device_flow_test.go
+++ b/internal/auth/device_flow_test.go
@@ -46,6 +46,10 @@ func writeServiceResult(w http.ResponseWriter, success bool, result any, errCode
func TestRequestDeviceCodeSuccess(t *testing.T) {
t.Parallel()
+ // Set a test client ID
+ SetClientID("test-client-id")
+ t.Cleanup(func() { SetClientID("") })
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go
index af5aa791..87dadbae 100644
--- a/internal/auth/endpoints.go
+++ b/internal/auth/endpoints.go
@@ -16,6 +16,7 @@ package auth
import (
"os"
"path/filepath"
+ "strings"
"sync"
)
@@ -59,16 +60,107 @@ const (
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
LogoutContinueURL = "https://login.dingtalk.com"
+
+ // MCP API endpoints for CLI authorization management.
+ DefaultMCPBaseURL = "https://mcp.dingtalk.com"
+ CLIAuthEnabledPath = "/cli/cliAuthEnabled"
+ SuperAdminPath = "/cli/superAdmin"
+ SendCliAuthApplyPath = "/cli/sendCliAuthApply"
+ ClientIDPath = "/cli/clientId"
+
+ // MCP OAuth endpoints (used when clientId is fetched from MCP).
+ MCPOAuthTokenPath = "/oauth2/getToken"
+ MCPRefreshTokenPath = "/oauth2/refreshToken"
+ MCPRevokeTokenPath = "/oauth2/revokeToken"
)
+// GetMCPBaseURL returns the MCP base URL with priority:
+// 1. ~/.dws/mcp_url file content (for pre-release environment)
+// 2. Default value (https://mcp.dingtalk.com)
+func GetMCPBaseURL() string {
+ mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url")
+ if data, err := os.ReadFile(mcpURLPath); err == nil {
+ if url := strings.TrimSpace(string(data)); url != "" {
+ return url
+ }
+ }
+ return DefaultMCPBaseURL
+}
+
// Runtime overrides set via CLI flags (--client-id, --client-secret).
// These take highest priority over environment variables and defaults.
var (
clientMu sync.RWMutex
runtimeClientID string
runtimeClientSecret string
+ // clientIDFromMCP indicates whether the clientID was fetched from MCP server.
+ // When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
+ clientIDFromMCP bool
)
+// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
+func SetClientIDFromMCP(id string) {
+ clientMu.Lock()
+ defer clientMu.Unlock()
+ runtimeClientID = id
+ clientIDFromMCP = true
+}
+
+// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server.
+func IsClientIDFromMCP() bool {
+ clientMu.RLock()
+ defer clientMu.RUnlock()
+ return clientIDFromMCP
+}
+
+// GetUserAccessTokenURL returns the appropriate token exchange URL.
+// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
+func GetUserAccessTokenURL() string {
+ if IsClientIDFromMCP() {
+ return GetMCPBaseURL() + MCPOAuthTokenPath
+ }
+ return UserAccessTokenURL
+}
+
+// GetRefreshTokenURL returns the appropriate token refresh URL.
+// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
+func GetRefreshTokenURL() string {
+ if IsClientIDFromMCP() {
+ return GetMCPBaseURL() + MCPRefreshTokenPath
+ }
+ return UserAccessTokenURL // DingTalk uses same endpoint for refresh
+}
+
+// GetRevokeTokenURL returns the token revocation URL (MCP only).
+// Returns empty string if not using MCP mode.
+func GetRevokeTokenURL() string {
+ if IsClientIDFromMCP() {
+ return GetMCPBaseURL() + MCPRevokeTokenPath
+ }
+ return "" // Direct mode doesn't have revoke endpoint
+}
+
+// resolveCredentialSource determines the source of the current credentials.
+// Returns one of: "flag", "env", "app", "default".
+// This is used to track where credentials came from for token refresh.
+func resolveCredentialSource() string {
+ clientMu.RLock()
+ hasRuntimeOverride := runtimeClientID != "" || runtimeClientSecret != ""
+ clientMu.RUnlock()
+
+ if hasRuntimeOverride {
+ return "flag"
+ }
+ // Check if loaded from app config
+ if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
+ return "app"
+ }
+ if os.Getenv("DWS_CLIENT_ID") != "" || os.Getenv("DWS_CLIENT_SECRET") != "" {
+ return "env"
+ }
+ return "default"
+}
+
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
func SetClientID(id string) {
clientMu.Lock()
@@ -87,7 +179,9 @@ func SetClientSecret(secret string) {
// 1. Runtime override (CLI flag --client-id)
// 2. Persisted app config (from previous login)
// 3. Environment variable (DWS_CLIENT_ID)
-// 4. Default hardcoded value
+// 4. Default hardcoded value (if not a placeholder)
+// Returns empty string if no valid client ID is available.
+// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login()
func ClientID() string {
clientMu.RLock()
override := runtimeClientID
@@ -102,7 +196,11 @@ func ClientID() string {
if v := os.Getenv("DWS_CLIENT_ID"); v != "" {
return v
}
- return DefaultClientID
+ // Only return default if it's not a placeholder
+ if !strings.HasPrefix(DefaultClientID, "<") {
+ return DefaultClientID
+ }
+ return ""
}
// ClientSecret returns the OAuth client secret with priority:
@@ -127,6 +225,13 @@ func ClientSecret() string {
return DefaultClientSecret
}
+// HasValidClientSecret returns true if a valid client secret is available.
+// A valid secret is one that is not a placeholder (e.g., ).
+func HasValidClientSecret() bool {
+ secret := ClientSecret()
+ return secret != "" && !strings.HasPrefix(secret, "<")
+}
+
// getRuntimeCredentials returns the runtime-override credentials if set.
// Returns empty strings if no runtime overrides were provided.
func getRuntimeCredentials() (clientID, clientSecret string) {
diff --git a/internal/auth/filelock.go b/internal/auth/filelock.go
index de266939..f5ca8ce1 100644
--- a/internal/auth/filelock.go
+++ b/internal/auth/filelock.go
@@ -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 (
diff --git a/internal/auth/identity.go b/internal/auth/identity.go
index 66f287fa..c800610d 100644
--- a/internal/auth/identity.go
+++ b/internal/auth/identity.go
@@ -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
}
diff --git a/internal/auth/keychain_store.go b/internal/auth/keychain_store.go
index 635d52a7..1ab513f6 100644
--- a/internal/auth/keychain_store.go
+++ b/internal/auth/keychain_store.go
@@ -105,3 +105,45 @@ func EnsureMigration(configDir string, logger *slog.Logger) {
func IsMigrationDone() bool {
return migrationDone
}
+
+// Client credential storage functions.
+// These store the clientSecret associated with a specific clientId,
+// allowing token refresh to work even if environment variables change.
+
+const clientSecretPrefix = "client-secret:"
+
+// SaveClientSecret stores the client secret for a specific client ID.
+// This is called during login to snapshot the credentials used.
+func SaveClientSecret(clientID, clientSecret string) error {
+ if clientID == "" || clientSecret == "" {
+ return nil // Nothing to save
+ }
+ account := clientSecretPrefix + clientID
+ if err := keychain.Set(keychain.Service, account, clientSecret); err != nil {
+ return fmt.Errorf("save client secret: %w", err)
+ }
+ return nil
+}
+
+// LoadClientSecret retrieves the stored client secret for a specific client ID.
+// Returns empty string if not found.
+func LoadClientSecret(clientID string) string {
+ if clientID == "" {
+ return ""
+ }
+ account := clientSecretPrefix + clientID
+ secret, err := keychain.Get(keychain.Service, account)
+ if err != nil {
+ return ""
+ }
+ return secret
+}
+
+// DeleteClientSecret removes the stored client secret for a specific client ID.
+func DeleteClientSecret(clientID string) error {
+ if clientID == "" {
+ return nil
+ }
+ account := clientSecretPrefix + clientID
+ return keychain.Remove(keychain.Service, account)
+}
diff --git a/internal/auth/manager.go b/internal/auth/manager.go
index c74cec3e..41c6298b 100644
--- a/internal/auth/manager.go
+++ b/internal/auth/manager.go
@@ -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"
diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go
index a5a97ce7..0a2fceab 100644
--- a/internal/auth/oauth_helpers.go
+++ b/internal/auth/oauth_helpers.go
@@ -23,13 +23,20 @@ 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) {
+ // Use MCP mode if clientID is from MCP server
+ if IsClientIDFromMCP() {
+ return p.exchangeCodeViaMCP(ctx, code)
+ }
+ // Direct mode with client secret
+ clientID := ClientID()
+ clientSecret := ClientSecret()
body := map[string]string{
- "clientId": ClientID(),
- "clientSecret": ClientSecret(),
+ "clientId": clientID,
+ "clientSecret": clientSecret,
"code": code,
"grantType": "authorization_code",
}
@@ -37,13 +44,72 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
if err != nil {
return nil, err
}
- return p.parseTokenResponse(resp)
+ data, err := p.parseTokenResponse(resp)
+ if err != nil {
+ return nil, err
+ }
+ // Snapshot credentials used for this token (for refresh)
+ data.ClientID = clientID
+ data.Source = resolveCredentialSource()
+ // Save clientSecret for future refresh (even if env changes)
+ if err := SaveClientSecret(clientID, clientSecret); err != nil {
+ // Log warning but don't fail login
+ fmt.Fprintf(p.Output, "Warning: failed to save client secret: %v\n", err)
+ }
+ return data, nil
+}
+
+// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
+// This is used when client secret is not available (server-side secret management).
+func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
+ clientID := ClientID()
+ url := GetMCPBaseURL() + MCPOAuthTokenPath
+ body := map[string]string{
+ "clientId": clientID,
+ "authCode": code,
+ "grantType": "authorization_code",
+ }
+ resp, err := p.postJSON(ctx, url, body)
+ if err != nil {
+ return nil, err
+ }
+ data, err := p.parseMCPTokenResponse(resp)
+ if err != nil {
+ return nil, err
+ }
+ // Snapshot credentials used for this token (for refresh)
+ data.ClientID = clientID
+ data.Source = "mcp"
+ // MCP mode doesn't need to save clientSecret (server-side managed)
+ return data, nil
}
func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) {
+ // Use stored Source to determine refresh path (not current runtime state)
+ // This ensures refresh works even if environment variables changed since login
+ if data.Source == "mcp" {
+ return p.refreshViaMCP(ctx, data)
+ }
+
+ // Direct mode: use stored clientId and load saved clientSecret
+ clientID := data.ClientID
+ if clientID == "" {
+ // Fallback for legacy tokens without stored clientId
+ clientID = ClientID()
+ }
+ clientSecret := LoadClientSecret(clientID)
+ if clientSecret == "" {
+ // Fallback: try current environment
+ clientSecret = ClientSecret()
+ }
+
+ if clientID == "" || clientSecret == "" {
+ return nil, fmt.Errorf("无法刷新 token: 缺少 clientId 或 clientSecret,请重新登录")
+ }
+
body := map[string]string{
- "clientId": ClientID(),
- "clientSecret": ClientSecret(),
+ "clientId": clientID,
+ "clientSecret": clientSecret,
"refreshToken": data.RefreshToken,
"grantType": "refresh_token",
}
@@ -55,6 +121,51 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
if err != nil {
return nil, err
}
+ // Preserve original credentials info
+ updated.ClientID = data.ClientID
+ updated.Source = data.Source
+ updated.PersistentCode = data.PersistentCode
+ updated.CorpID = data.CorpID
+ updated.UserID = data.UserID
+ updated.UserName = data.UserName
+ updated.CorpName = data.CorpName
+
+ if err := SaveTokenData(p.configDir, updated); err != nil {
+ return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
+ }
+ return updated, nil
+}
+
+// refreshViaMCP refreshes token via MCP proxy.
+func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) {
+ // Use stored clientId from token data
+ clientID := data.ClientID
+ if clientID == "" {
+ // Fallback for legacy tokens
+ clientID = ClientID()
+ }
+
+ if clientID == "" {
+ return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
+ }
+
+ url := GetMCPBaseURL() + MCPRefreshTokenPath
+ body := map[string]string{
+ "clientId": clientID,
+ "refreshToken": data.RefreshToken,
+ "grantType": "refresh_token",
+ }
+ resp, err := p.postJSON(ctx, url, body)
+ if err != nil {
+ return nil, err
+ }
+ updated, err := p.parseMCPTokenResponse(resp)
+ if err != nil {
+ return nil, err
+ }
+ // Preserve original credentials info
+ updated.ClientID = data.ClientID
+ updated.Source = data.Source
updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID
updated.UserID = data.UserID
@@ -132,6 +243,48 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
+// parseMCPTokenResponse parses token response from MCP proxy.
+// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
+func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
+ var resp struct {
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken"`
+ PersistentCode string `json:"persistentCode"`
+ ExpiresIn int64 `json:"expiresIn"`
+ CorpID string `json:"corpId"`
+ // Error fields (when request fails)
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, fmt.Errorf("parsing MCP token response: %w (body: %s)", err, string(body))
+ }
+ // Check for error response
+ if resp.ErrorCode != "" || resp.ErrorMsg != "" {
+ return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg)
+ }
+ if resp.AccessToken == "" {
+ return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
+ }
+
+ now := time.Now()
+ expiresIn := resp.ExpiresIn
+ if expiresIn <= 0 {
+ expiresIn = config.DefaultAccessTokenExpiry
+ }
+ data := &TokenData{
+ AccessToken: resp.AccessToken,
+ RefreshToken: resp.RefreshToken,
+ ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
+ RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
+ CorpID: resp.CorpID,
+ }
+ if resp.PersistentCode != "" {
+ data.PersistentCode = resp.PersistentCode
+ }
+ return data, nil
+}
+
func buildAuthURL(clientID, redirectURI string) string {
params := url.Values{
"client_id": {clientID},
@@ -143,9 +296,866 @@ func buildAuthURL(clientID, redirectURI string) string {
return AuthorizeURL + "?" + params.Encode()
}
-const successHTML = `
-钉钉 CLI
-
-`
+const successHTML = `
+
+
+
+ 钉钉 CLI
+
+
+
+
+
+
授权成功
+
请返回终端继续操作。此页面可以关闭。
+
+
+`
+
+const notEnabledHTML = `
+
+
+
+
+ 钉钉 CLI
+
+
+
+
+
+
该组织尚未开启 CLI 数据访问权限
+
+ 你所选择的组织管理员尚未开启 「允许成员通过 CLI
+ 访问其个人数据」的权限。
+
+
+
+
+
+
+
+
+
已向管理员发送权限申请,正在等待审核 审核通过后,请返回终端继续操作
+
+
+
立即申请
+
返回选择其他组织
+
+
+
+
+`
+
+// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
+type CLIAuthStatus struct {
+ Success bool `json:"success"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ Result struct {
+ CLIAuthEnabled bool `json:"cliAuthEnabled"`
+ } `json:"result"`
+}
+
+// SuperAdmin represents a corp super admin.
+type SuperAdmin struct {
+ StaffID string `json:"staffId"`
+ Name string `json:"name"`
+}
+
+// SuperAdminResponse represents the response from /cli/superAdmin API.
+type SuperAdminResponse struct {
+ Success bool `json:"success"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ Result []SuperAdmin `json:"result"`
+}
+
+// SendApplyResponse represents the response from /cli/sendCliAuthApply API.
+type SendApplyResponse struct {
+ Success bool `json:"success"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ 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 {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+ req.Header.Set("x-user-access-token", accessToken)
+
+ client := p.httpClient
+ if client == nil {
+ client = oauthHTTPClient
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("sending request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ var status CLIAuthStatus
+ if err := json.Unmarshal(data, &status); err != nil {
+ return nil, fmt.Errorf("parsing response: %w", err)
+ }
+ return &status, nil
+}
+
+// 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 {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+ req.Header.Set("x-user-access-token", accessToken)
+
+ resp, err := oauthHTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("sending request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ var result SuperAdminResponse
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("parsing response: %w", err)
+ }
+ return &result, nil
+}
+
+// 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 {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+ req.Header.Set("x-user-access-token", accessToken)
+
+ resp, err := oauthHTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("sending request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ var result SendApplyResponse
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("parsing response: %w", err)
+ }
+ return &result, nil
+}
+
+// ClientIDResponse represents the response from /cli/clientId API.
+type ClientIDResponse struct {
+ Success bool `json:"success"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ Result string `json:"result"`
+}
+
+// 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 {
+ return "", fmt.Errorf("creating request: %w", err)
+ }
+
+ resp, err := oauthHTTPClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("sending request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
+ if err != nil {
+ return "", fmt.Errorf("reading response: %w", err)
+ }
+
+ var result ClientIDResponse
+ if err := json.Unmarshal(data, &result); err != nil {
+ return "", fmt.Errorf("parsing response: %w", err)
+ }
+ if !result.Success {
+ return "", fmt.Errorf("%s: %s", result.ErrorCode, result.ErrorMsg)
+ }
+ return result.Result, nil
+}
diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go
index 02dffe6f..071f9237 100644
--- a/internal/auth/oauth_provider.go
+++ b/internal/auth/oauth_provider.go
@@ -15,6 +15,7 @@ package auth
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"io"
@@ -22,6 +23,7 @@ import (
"net"
"net/http"
"os"
+ "sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
@@ -92,6 +94,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Fall through: full browser OAuth flow.
+ // Ensure we have a valid client ID (fetch from MCP if not available)
+ if p.clientID == "" {
+ if p.logger != nil {
+ p.logger.Debug("client ID not configured, fetching from MCP server")
+ }
+ mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
+ if mcpErr != nil {
+ return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
+ }
+ p.clientID = mcpClientID
+ // Mark that clientID is from MCP, so we use MCP OAuth endpoints
+ SetClientIDFromMCP(mcpClientID)
+ if p.logger != nil {
+ p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
+ }
+ }
+
// Find a free port for the callback server.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -100,15 +119,79 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath)
- codeCh := make(chan string, 1)
+ // Channel to pass callback result (token data or error with CLI auth status)
+ type callbackResult struct {
+ token *TokenData
+ err error
+ cliAuthDisabled bool
+ }
+ resultCh := make(chan callbackResult, 1)
errCh := make(chan error, 1)
+ // Shared state for API handlers (protected by mutex)
+ var (
+ callbackToken *TokenData
+ callbackProcessedCode string // The auth code that has been successfully processed
+ callbackAuthDisabled bool
+ callbackApplySent bool // Whether apply request was sent
+ callbackSelectedAdminId string // Selected admin ID for apply
+ callbackCodeInProgress string // Code currently being processed (to prevent concurrent exchange)
+ callbackTokenMu sync.Mutex
+ )
+
mux := http.NewServeMux()
mux.HandleFunc(CallbackPath, func(w http.ResponseWriter, r *http.Request) {
+ // Get code first to check if this is a new authorization or page refresh
code := r.URL.Query().Get("authCode")
if code == "" {
code = r.URL.Query().Get("code")
}
+
+ // Check state and handle page refresh or concurrent requests
+ callbackTokenMu.Lock()
+ processedCode := callbackProcessedCode
+ processedAuthDisabled := callbackAuthDisabled
+ codeInProgress := callbackCodeInProgress
+ hasToken := callbackToken != nil
+
+ // Case 1: This code was already successfully processed - show cached page
+ if code != "" && code == processedCode {
+ callbackTokenMu.Unlock()
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if processedAuthDisabled {
+ _, _ = fmt.Fprint(w, notEnabledHTML)
+ } else {
+ _, _ = fmt.Fprint(w, successHTML)
+ }
+ return
+ }
+
+ // Case 2: This code is being processed by another request - show wait page
+ if code != "" && code == codeInProgress {
+ callbackTokenMu.Unlock()
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = fmt.Fprint(w, `正在处理授权,请稍候...
`)
+ return
+ }
+
+ // Case 3: No code but we have a processed token - show cached page
+ if code == "" && hasToken {
+ callbackTokenMu.Unlock()
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if processedAuthDisabled {
+ _, _ = fmt.Fprint(w, notEnabledHTML)
+ } else {
+ _, _ = fmt.Fprint(w, successHTML)
+ }
+ return
+ }
+
+ // Case 4: New code - mark as in-progress and process
+ if code != "" {
+ callbackCodeInProgress = code
+ }
+ callbackTokenMu.Unlock()
+
if code == "" {
select {
case errCh <- errors.New(i18n.T("回调中未收到授权码")):
@@ -118,24 +201,151 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_, _ = fmt.Fprint(w, i18n.T("授权失败:未收到授权码"))
return
}
- // Write response FIRST before notifying main goroutine
- // This prevents race condition where server.Shutdown is called
- // before response is fully sent
+
+ // Exchange code for token
+ tokenData, exchangeErr := p.exchangeCode(ctx, code)
+ if exchangeErr != nil {
+ // Clear in-progress state on error
+ callbackTokenMu.Lock()
+ if callbackCodeInProgress == code {
+ callbackCodeInProgress = ""
+ }
+ callbackTokenMu.Unlock()
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = fmt.Fprintf(w, "授权失败 %s
", exchangeErr.Error())
+ select {
+ case resultCh <- callbackResult{err: exchangeErr}:
+ default:
+ }
+ return
+ }
+
+ // Mark as processed immediately after successful exchange
+ callbackTokenMu.Lock()
+ previouslyProcessed := callbackProcessedCode != ""
+ callbackToken = tokenData
+ callbackProcessedCode = code // Remember this code was successfully processed
+ callbackCodeInProgress = "" // Clear in-progress state
+ // Reset apply state for new authorization (user switched org)
+ if previouslyProcessed {
+ callbackApplySent = false
+ callbackSelectedAdminId = ""
+ }
+ callbackTokenMu.Unlock()
+
+ // Check CLI auth enabled status (fail-closed: treat errors as disabled)
+ authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
+ cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
+
+ // Update CLI auth disabled state
+ callbackTokenMu.Lock()
+ callbackAuthDisabled = !cliAuthEnabled
+ callbackTokenMu.Unlock()
+
+ // Display appropriate HTML based on CLI auth status
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _, _ = fmt.Fprint(w, successHTML)
+ if !cliAuthEnabled {
+ _, _ = fmt.Fprint(w, notEnabledHTML)
+ } else {
+ _, _ = fmt.Fprint(w, successHTML)
+ }
// Ensure response is flushed to client
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
- // Now notify main goroutine (non-blocking)
+ // Notify main goroutine with full result
select {
- case codeCh <- code:
- // Success
+ case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}:
default:
- // Select already exited (timeout/cancel); code discarded but response already sent
}
})
+ // API endpoint: get super admins
+ mux.HandleFunc("/api/superAdmin", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ callbackTokenMu.Lock()
+ token := callbackToken
+ callbackTokenMu.Unlock()
+ if token == nil {
+ _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
+ return
+ }
+ result, err := GetSuperAdmins(ctx, token.AccessToken)
+ if err != nil {
+ _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
+ return
+ }
+ data, _ := json.Marshal(result)
+ _, _ = w.Write(data)
+ })
+
+ // API endpoint: send CLI auth apply
+ mux.HandleFunc("/api/sendApply", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ adminStaffID := r.URL.Query().Get("adminStaffId")
+ if adminStaffID == "" {
+ _, _ = w.Write([]byte(`{"success":false,"errorMsg":"缺少 adminStaffId 参数"}`))
+ return
+ }
+ callbackTokenMu.Lock()
+ token := callbackToken
+ callbackTokenMu.Unlock()
+ if token == nil {
+ _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
+ return
+ }
+ result, err := SendCliAuthApply(ctx, token.AccessToken, adminStaffID)
+ if err != nil {
+ _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
+ return
+ }
+ // Mark apply as sent and save selected admin on success
+ if result.Success && result.Result {
+ callbackTokenMu.Lock()
+ callbackApplySent = true
+ callbackSelectedAdminId = adminStaffID
+ callbackTokenMu.Unlock()
+ }
+ data, _ := json.Marshal(result)
+ _, _ = w.Write(data)
+ })
+
+ // API endpoint: get current status (clientId, applySent, selectedAdminId)
+ mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ callbackTokenMu.Lock()
+ applySent := callbackApplySent
+ selectedAdminId := callbackSelectedAdminId
+ callbackTokenMu.Unlock()
+ _, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
+ })
+
+ // API endpoint: check CLI auth enabled status
+ mux.HandleFunc("/api/cliAuthEnabled", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ callbackTokenMu.Lock()
+ token := callbackToken
+ callbackTokenMu.Unlock()
+ if token == nil {
+ _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
+ return
+ }
+ result, err := p.CheckCLIAuthEnabled(ctx, token.AccessToken)
+ if err != nil {
+ _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
+ return
+ }
+ data, _ := json.Marshal(result)
+ _, _ = w.Write(data)
+ })
+
+ // Success page endpoint
+ mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = fmt.Fprint(w, successHTML)
+ })
+
server := &http.Server{Handler: mux}
go func() {
if serveErr := server.Serve(listener); !errors.Is(serveErr, http.ErrServerClosed) {
@@ -169,9 +379,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
timeout := time.NewTimer(5 * time.Minute)
defer timeout.Stop()
- var authCode string
+ var result callbackResult
select {
- case authCode = <-codeCh:
+ case result = <-resultCh:
case err := <-errCh:
return nil, err
case <-timeout.C:
@@ -180,11 +390,73 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return nil, ctx.Err()
}
- tokenData, err := p.exchangeCode(ctx, authCode)
- if err != nil {
- return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
+ // Handle callback errors
+ if result.err != nil {
+ return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err)
}
+ // Handle CLI auth disabled - keep server running for user to apply
+ if result.cliAuthDisabled {
+ _, _ = fmt.Fprintln(p.output(), "")
+ _, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请..."))
+
+ // Poll for CLI auth status while waiting
+ applyTimeout := time.NewTimer(10 * time.Minute)
+ defer applyTimeout.Stop()
+ pollTicker := time.NewTicker(5 * time.Second)
+ defer pollTicker.Stop()
+
+ elapsedSeconds := 0
+ for {
+ select {
+ case <-applyTimeout.C:
+ return nil, errors.New(i18n.T("操作超时,请重新登录"))
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-pollTicker.C:
+ elapsedSeconds += 5
+
+ // Get latest token and state (user may have switched org)
+ callbackTokenMu.Lock()
+ currentToken := callbackToken
+ currentAuthDisabled := callbackAuthDisabled
+ applySent := callbackApplySent
+ callbackTokenMu.Unlock()
+
+ // Check if user switched to an org with CLI auth enabled
+ if currentToken != nil && !currentAuthDisabled {
+ _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
+ time.Sleep(2 * time.Second)
+ result.token = currentToken
+ result.cliAuthDisabled = false
+ goto continueLogin
+ }
+
+ // Check if CLI auth is now enabled (admin approved)
+ if currentToken != nil {
+ authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken)
+ if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled {
+ _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录..."))
+ time.Sleep(2 * time.Second)
+ result.token = currentToken
+ result.cliAuthDisabled = false
+ goto continueLogin
+ }
+ }
+
+ // Show polling status based on apply state
+ if applySent {
+ _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待管理员审批中"), elapsedSeconds)
+ } else {
+ _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待提交申请中"), elapsedSeconds)
+ }
+ }
+ }
+ }
+
+continueLogin:
+ tokenData := result.token
+
// Save token data with associated client ID for refresh
tokenData.ClientID = p.clientID
if err := SaveTokenData(p.configDir, tokenData); err != nil {
diff --git a/internal/auth/secure_store.go b/internal/auth/secure_store.go
index addecc33..89af75ab 100644
--- a/internal/auth/secure_store.go
+++ b/internal/auth/secure_store.go
@@ -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"
diff --git a/internal/auth/token.go b/internal/auth/token.go
index 644eb638..4b7f9472 100644
--- a/internal/auth/token.go
+++ b/internal/auth/token.go
@@ -14,7 +14,9 @@
package auth
import (
+ "bytes"
"context"
+ "encoding/json"
"fmt"
"net/http"
"net/url"
@@ -103,10 +105,16 @@ func DeleteTokenData(configDir string) error {
return legacyErr
}
-// RevokeTokenRemote calls the DingTalk logout endpoint to invalidate the access token.
+// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
+// Uses MCP revoke endpoint when clientID is from MCP, otherwise uses DingTalk logout.
// This should be called before deleting local token data.
// The function is best-effort: errors are returned but callers may choose to ignore them.
func RevokeTokenRemote(ctx context.Context) error {
+ // Use MCP revoke endpoint when clientID is from MCP
+ if IsClientIDFromMCP() {
+ return revokeTokenViaMCP(ctx)
+ }
+ // Direct mode: use DingTalk logout endpoint
logoutURL, err := url.Parse(LogoutURL)
if err != nil {
return fmt.Errorf("parsing logout URL: %w", err)
@@ -143,3 +151,45 @@ func RevokeTokenRemote(ctx context.Context) error {
return nil
}
+
+// revokeTokenViaMCP revokes token via MCP endpoint.
+func revokeTokenViaMCP(ctx context.Context) error {
+ revokeURL := GetRevokeTokenURL()
+ if revokeURL == "" {
+ return nil // No revoke endpoint available
+ }
+
+ // Load current token to get accessToken
+ tokenData, err := LoadTokenData(getDefaultConfigDir())
+ if err != nil || tokenData == nil {
+ return nil // No token to revoke
+ }
+
+ body := map[string]string{
+ "clientId": ClientID(),
+ "accessToken": tokenData.AccessToken,
+ }
+ bodyBytes, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("marshaling revoke request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("creating revoke request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("calling revoke endpoint: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("revoke endpoint returned status %d", resp.StatusCode)
+ }
+
+ return nil
+}
diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go
index 01e1fc41..60caea9b 100644
--- a/internal/cli/canonical.go
+++ b/internal/cli/canonical.go
@@ -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"
)
diff --git a/internal/cli/loader.go b/internal/cli/loader.go
index c05439b8..085a3c78 100644
--- a/internal/cli/loader.go
+++ b/internal/cli/loader.go
@@ -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 (
diff --git a/internal/compat/registry.go b/internal/compat/registry.go
index cf6a40e7..3d47da67 100644
--- a/internal/compat/registry.go
+++ b/internal/compat/registry.go
@@ -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"
)
diff --git a/internal/errors/errors.go b/internal/errors/errors.go
index 9ba0a6a0..38e90ac2 100644
--- a/internal/errors/errors.go
+++ b/internal/errors/errors.go
@@ -253,6 +253,12 @@ func PrintJSON(w io.Writer, err error) error {
}
if typed.ServerDiag.ServerErrorCode != "" {
errorPayload["server_error_code"] = typed.ServerDiag.ServerErrorCode
+ // Add user-friendly hint for specific server error codes
+ switch typed.ServerDiag.ServerErrorCode {
+ case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
+ errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"
+ errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings"
+ }
}
if typed.ServerDiag.TechnicalDetail != "" {
errorPayload["technical_detail"] = typed.ServerDiag.TechnicalDetail
@@ -312,6 +318,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if typed.Hint != "" {
lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint))
}
+
+ // Add user-friendly hint for specific server error codes
+ switch typed.ServerDiag.ServerErrorCode {
+ case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
+ lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
+ lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings")
+ }
+
if len(typed.Actions) > 0 {
for _, action := range typed.Actions {
if strings.TrimSpace(action) == "" {
diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go
index 0489bc54..c315037d 100644
--- a/internal/helpers/aitable.go
+++ b/internal/helpers/aitable.go
@@ -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"
)
diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go
index d42d5e25..5d578e40 100644
--- a/internal/helpers/todo.go
+++ b/internal/helpers/todo.go
@@ -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 {
diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json
index 916f03ce..bfd36759 100644
--- a/internal/i18n/locales/en.json
+++ b/internal/i18n/locales/en.json
@@ -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"
}
diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json
index 2a029280..dbfe9e9b 100644
--- a/internal/i18n/locales/zh.json
+++ b/internal/i18n/locales/zh.json
@@ -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 授权状态...",
"🔐 登录钉钉": "🔐 登录钉钉"
}
diff --git a/internal/logging/logger.go b/internal/logging/logger.go
index 3f3dd0a6..a4c5b459 100644
--- a/internal/logging/logger.go
+++ b/internal/logging/logger.go
@@ -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 (
diff --git a/internal/market/registry.go b/internal/market/registry.go
index 070b622e..8b4ab679 100644
--- a/internal/market/registry.go
+++ b/internal/market/registry.go
@@ -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 (
diff --git a/internal/output/sanitize.go b/internal/output/sanitize.go
index 0d20df5d..95f333dd 100644
--- a/internal/output/sanitize.go
+++ b/internal/output/sanitize.go
@@ -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.
diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go
index 1cd4207a..21378917 100644
--- a/internal/pipeline/handlers/paramname.go
+++ b/internal/pipeline/handlers/paramname.go
@@ -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]
-}
diff --git a/internal/pipeline/handlers/paramname_test.go b/internal/pipeline/handlers/paramname_test.go
index ef3f6c07..b569b14b 100644
--- a/internal/pipeline/handlers/paramname_test.go
+++ b/internal/pipeline/handlers/paramname_test.go
@@ -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)
}
diff --git a/internal/security/storage.go b/internal/security/storage.go
index 43269e80..bd066720 100644
--- a/internal/security/storage.go
+++ b/internal/security/storage.go
@@ -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.
diff --git a/internal/transport/client.go b/internal/transport/client.go
index 072434a3..9b566fd7 100644
--- a/internal/transport/client.go
+++ b/internal/transport/client.go
@@ -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 (
diff --git a/internal/upgrade/downloader.go b/internal/upgrade/downloader.go
new file mode 100644
index 00000000..38584429
--- /dev/null
+++ b/internal/upgrade/downloader.go
@@ -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< 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
+}
diff --git a/internal/upgrade/downloader_test.go b/internal/upgrade/downloader_test.go
new file mode 100644
index 00000000..6a8cf0a2
--- /dev/null
+++ b/internal/upgrade/downloader_test.go
@@ -0,0 +1,174 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package upgrade
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestDownload_Success(t *testing.T) {
+ body := "hello world binary content"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
+ w.Write([]byte(body))
+ }))
+ defer server.Close()
+
+ dest := filepath.Join(t.TempDir(), "downloaded")
+ n, err := Download(server.URL+"/file.tar.gz", dest)
+ if err != nil {
+ t.Fatalf("Download() error = %v", err)
+ }
+ if n != int64(len(body)) {
+ t.Errorf("Download() = %d bytes, want %d", n, len(body))
+ }
+
+ got, _ := os.ReadFile(dest)
+ if string(got) != body {
+ t.Errorf("file content = %q, want %q", string(got), body)
+ }
+}
+
+func TestDownload_HTTP404(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ dest := filepath.Join(t.TempDir(), "notfound")
+ _, err := Download(server.URL+"/missing", dest)
+ if err == nil {
+ t.Fatal("Download() expected error for 404")
+ }
+}
+
+func TestDownload_HTTP500_RetriesAndFails(t *testing.T) {
+ var attempts int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&attempts, 1)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ dest := filepath.Join(t.TempDir(), "err")
+ cfg := &DownloadConfig{
+ MaxRetries: 2,
+ Timeout: 5 * time.Second,
+ }
+ _, err := DownloadWithConfig(context.Background(), server.URL+"/fail", dest, cfg)
+ if err == nil {
+ t.Fatal("expected error after retries")
+ }
+
+ // 500 is not retriable (no "connection reset" pattern), so only 1 attempt
+ got := atomic.LoadInt32(&attempts)
+ if got != 1 {
+ t.Errorf("attempts = %d, want 1 (500 is not retriable)", got)
+ }
+}
+
+func TestDownload_ContextCancellation(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(2 * time.Second)
+ w.Write([]byte("late"))
+ }))
+ defer server.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // cancel immediately
+
+ dest := filepath.Join(t.TempDir(), "cancelled")
+ _, err := DownloadWithProgress(ctx, server.URL+"/slow", dest, nil)
+ if err == nil {
+ t.Fatal("expected error for cancelled context")
+ }
+}
+
+func TestDownloadWithProgress_Callback(t *testing.T) {
+ body := make([]byte, 1024)
+ for i := range body {
+ body[i] = 'A'
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
+ w.Write(body)
+ }))
+ defer server.Close()
+
+ var called int32
+ dest := filepath.Join(t.TempDir(), "progress")
+ n, err := DownloadWithProgress(context.Background(), server.URL+"/file", dest,
+ func(percent float64, downloaded, total int64) {
+ atomic.AddInt32(&called, 1)
+ })
+ if err != nil {
+ t.Fatalf("DownloadWithProgress() error = %v", err)
+ }
+ if n != int64(len(body)) {
+ t.Errorf("bytes = %d, want %d", n, len(body))
+ }
+ if atomic.LoadInt32(&called) == 0 {
+ t.Error("progress callback was never called")
+ }
+}
+
+func TestDownload_CreatesParentDirectories(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("data"))
+ }))
+ defer server.Close()
+
+ dest := filepath.Join(t.TempDir(), "a", "b", "c", "file")
+ _, err := Download(server.URL+"/file", dest)
+ if err != nil {
+ t.Fatalf("Download() error = %v", err)
+ }
+ if _, err := os.Stat(dest); err != nil {
+ t.Errorf("file not created: %v", err)
+ }
+}
+
+func TestDefaultDownloadConfig(t *testing.T) {
+ cfg := DefaultDownloadConfig()
+ if cfg.MaxRetries != 3 {
+ t.Errorf("MaxRetries = %d, want 3", cfg.MaxRetries)
+ }
+ if cfg.Timeout != 10*time.Minute {
+ t.Errorf("Timeout = %v, want 10m", cfg.Timeout)
+ }
+ if cfg.ProgressInterval != 200*time.Millisecond {
+ t.Errorf("ProgressInterval = %v, want 200ms", cfg.ProgressInterval)
+ }
+}
+
+func TestIsRetriable(t *testing.T) {
+ tests := []struct {
+ err error
+ want bool
+ }{
+ {nil, false},
+ {fmt.Errorf("connection reset by peer"), true},
+ {fmt.Errorf("Connection Refused"), true},
+ {fmt.Errorf("request timeout exceeded"), true},
+ {fmt.Errorf("temporary failure in name resolution"), true},
+ {fmt.Errorf("unexpected EOF"), true},
+ {fmt.Errorf("permission denied"), false},
+ {fmt.Errorf("file not found"), false},
+ }
+ for _, tt := range tests {
+ got := isRetriable(tt.err)
+ if got != tt.want {
+ t.Errorf("isRetriable(%q) = %v, want %v", tt.err, got, tt.want)
+ }
+ }
+}
diff --git a/internal/upgrade/github.go b/internal/upgrade/github.go
new file mode 100644
index 00000000..cccff7d7
--- /dev/null
+++ b/internal/upgrade/github.go
@@ -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)
+}
diff --git a/internal/upgrade/github_test.go b/internal/upgrade/github_test.go
new file mode 100644
index 00000000..a50fff96
--- /dev/null
+++ b/internal/upgrade/github_test.go
@@ -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
+}
diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go
new file mode 100644
index 00000000..9bbe0678
--- /dev/null
+++ b/internal/upgrade/paths.go
@@ -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
+}
diff --git a/internal/upgrade/paths_test.go b/internal/upgrade/paths_test.go
new file mode 100644
index 00000000..b2088f71
--- /dev/null
+++ b/internal/upgrade/paths_test.go
@@ -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])
+ }
+}
diff --git a/internal/upgrade/replacer.go b/internal/upgrade/replacer.go
new file mode 100644
index 00000000..c4fd25fd
--- /dev/null
+++ b/internal/upgrade/replacer.go
@@ -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()
+}
diff --git a/internal/upgrade/replacer_test.go b/internal/upgrade/replacer_test.go
new file mode 100644
index 00000000..96764504
--- /dev/null
+++ b/internal/upgrade/replacer_test.go
@@ -0,0 +1,264 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package upgrade
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestExtractZip_BasicFiles(t *testing.T) {
+ dir := t.TempDir()
+ zipPath := filepath.Join(dir, "test.zip")
+ targetDir := filepath.Join(dir, "extracted")
+
+ createTestZip(t, zipPath, map[string]string{
+ "README.md": "# Hello",
+ "dws/SKILL.md": "# Skill",
+ "dws/data.txt": "some data",
+ })
+
+ if err := ExtractZip(zipPath, targetDir); err != nil {
+ t.Fatalf("ExtractZip() error = %v", err)
+ }
+
+ assertFileContent(t, filepath.Join(targetDir, "README.md"), "# Hello")
+ assertFileContent(t, filepath.Join(targetDir, "dws", "SKILL.md"), "# Skill")
+ assertFileContent(t, filepath.Join(targetDir, "dws", "data.txt"), "some data")
+}
+
+func TestExtractZip_ZipSlipProtection(t *testing.T) {
+ dir := t.TempDir()
+ zipPath := filepath.Join(dir, "evil.zip")
+ targetDir := filepath.Join(dir, "extracted")
+
+ f, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := zip.NewWriter(f)
+ // Attempt path traversal
+ ew, _ := w.Create("../../etc/passwd")
+ ew.Write([]byte("root:x:0:0"))
+ // Normal file
+ nw, _ := w.Create("normal.txt")
+ nw.Write([]byte("ok"))
+ w.Close()
+ f.Close()
+
+ if err := ExtractZip(zipPath, targetDir); err != nil {
+ t.Fatalf("ExtractZip() error = %v", err)
+ }
+
+ // Path-traversal entry should be skipped
+ evilPath := filepath.Join(dir, "..", "etc", "passwd")
+ if _, err := os.Stat(evilPath); err == nil {
+ t.Error("zip-slip protection failed: traversal file was extracted")
+ }
+
+ // Normal file should exist
+ assertFileContent(t, filepath.Join(targetDir, "normal.txt"), "ok")
+}
+
+func TestExtractZip_InvalidZip(t *testing.T) {
+ dir := t.TempDir()
+ badPath := filepath.Join(dir, "bad.zip")
+ os.WriteFile(badPath, []byte("not a zip"), 0644)
+
+ err := ExtractZip(badPath, filepath.Join(dir, "out"))
+ if err == nil {
+ t.Error("ExtractZip() expected error for invalid zip")
+ }
+}
+
+func TestExtractZip_EmptyZip(t *testing.T) {
+ dir := t.TempDir()
+ zipPath := filepath.Join(dir, "empty.zip")
+ createTestZip(t, zipPath, map[string]string{})
+
+ targetDir := filepath.Join(dir, "extracted")
+ if err := ExtractZip(zipPath, targetDir); err != nil {
+ t.Fatalf("ExtractZip() error = %v for empty zip", err)
+ }
+}
+
+func TestFindBinaryInDir_TopLevel(t *testing.T) {
+ dir := t.TempDir()
+ dwsPath := filepath.Join(dir, "dws")
+ os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
+
+ got := FindBinaryInDir(dir)
+ if got != dwsPath {
+ t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
+ }
+}
+
+func TestFindBinaryInDir_Nested(t *testing.T) {
+ dir := t.TempDir()
+ nested := filepath.Join(dir, "subdir")
+ os.MkdirAll(nested, 0755)
+ dwsPath := filepath.Join(nested, "dws")
+ os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
+
+ got := FindBinaryInDir(dir)
+ if got != dwsPath {
+ t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
+ }
+}
+
+func TestFindBinaryInDir_Windows(t *testing.T) {
+ dir := t.TempDir()
+ exePath := filepath.Join(dir, "dws.exe")
+ os.WriteFile(exePath, []byte("MZ"), 0755)
+
+ got := FindBinaryInDir(dir)
+ if got != exePath {
+ t.Errorf("FindBinaryInDir() = %q, want %q", got, exePath)
+ }
+}
+
+func TestFindBinaryInDir_NotFound(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "other.bin"), []byte("x"), 0755)
+
+ got := FindBinaryInDir(dir)
+ if got != "" {
+ t.Errorf("FindBinaryInDir() = %q, want empty", got)
+ }
+}
+
+func TestCopyFile(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "src")
+ dst := filepath.Join(dir, "dst")
+
+ os.WriteFile(src, []byte("copy me"), 0644)
+
+ if err := copyFile(src, dst, 0755); err != nil {
+ t.Fatalf("copyFile() error = %v", err)
+ }
+
+ got, _ := os.ReadFile(dst)
+ if string(got) != "copy me" {
+ t.Errorf("dst content = %q, want %q", string(got), "copy me")
+ }
+
+ info, _ := os.Stat(dst)
+ if info.Mode().Perm()&0755 != 0755 {
+ t.Errorf("dst perm = %o, want 0755", info.Mode().Perm())
+ }
+}
+
+func TestCopyFile_SrcNotExist(t *testing.T) {
+ dir := t.TempDir()
+ err := copyFile(filepath.Join(dir, "missing"), filepath.Join(dir, "dst"), 0644)
+ if err == nil {
+ t.Error("copyFile() expected error for missing src")
+ }
+}
+
+func TestReplaceExeFile_AtomicRename(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "new-binary")
+ dst := filepath.Join(dir, "dws")
+
+ os.WriteFile(dst, []byte("old"), 0755)
+ os.WriteFile(src, []byte("new"), 0755)
+
+ if err := replaceExeFile(src, dst); err != nil {
+ t.Fatalf("replaceExeFile() error = %v", err)
+ }
+
+ got, _ := os.ReadFile(dst)
+ if string(got) != "new" {
+ t.Errorf("dst content = %q, want %q", string(got), "new")
+ }
+
+ // src should no longer exist (it was renamed)
+ if _, err := os.Stat(src); err == nil {
+ t.Error("src should have been renamed away")
+ }
+}
+
+func TestWindowsReplace_Basic(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "new-binary")
+ dst := filepath.Join(dir, "dws.exe")
+
+ os.WriteFile(dst, []byte("old-exe"), 0755)
+ os.WriteFile(src, []byte("new-exe"), 0755)
+
+ if err := windowsReplace(src, dst); err != nil {
+ t.Fatalf("windowsReplace() error = %v", err)
+ }
+
+ got, _ := os.ReadFile(dst)
+ if string(got) != "new-exe" {
+ t.Errorf("dst content = %q, want %q", string(got), "new-exe")
+ }
+
+ // .old should have been cleaned up
+ if _, err := os.Stat(dst + ".old"); err == nil {
+ t.Error(".old file should have been cleaned up")
+ }
+}
+
+func TestWindowsReplace_CleansUpStaleOld(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "new-binary")
+ dst := filepath.Join(dir, "dws.exe")
+ oldPath := dst + ".old"
+
+ os.WriteFile(dst, []byte("current"), 0755)
+ os.WriteFile(src, []byte("newer"), 0755)
+ os.WriteFile(oldPath, []byte("stale-old"), 0755)
+
+ if err := windowsReplace(src, dst); err != nil {
+ t.Fatalf("windowsReplace() error = %v", err)
+ }
+
+ got, _ := os.ReadFile(dst)
+ if string(got) != "newer" {
+ t.Errorf("dst content = %q, want %q", string(got), "newer")
+ }
+}
+
+func TestCleanupStaleFiles(t *testing.T) {
+ // Just verify it doesn't panic; the actual files to clean depend on os.Executable()
+ CleanupStaleFiles()
+}
+
+// --- helpers ---
+
+func createTestZip(t *testing.T, zipPath string, files map[string]string) {
+ t.Helper()
+ f, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := zip.NewWriter(f)
+ for name, content := range files {
+ fw, err := w.Create(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fw.Write([]byte(content))
+ }
+ w.Close()
+ f.Close()
+}
+
+func assertFileContent(t *testing.T, path, want string) {
+ t.Helper()
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Errorf("cannot read %s: %v", path, err)
+ return
+ }
+ if string(got) != want {
+ t.Errorf("content of %s = %q, want %q", path, string(got), want)
+ }
+}
diff --git a/internal/upgrade/rollback.go b/internal/upgrade/rollback.go
new file mode 100644
index 00000000..9431b09b
--- /dev/null
+++ b/internal/upgrade/rollback.go
@@ -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
+}
diff --git a/internal/upgrade/rollback_test.go b/internal/upgrade/rollback_test.go
new file mode 100644
index 00000000..74c34b43
--- /dev/null
+++ b/internal/upgrade/rollback_test.go
@@ -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
+}
diff --git a/internal/upgrade/verify.go b/internal/upgrade/verify.go
new file mode 100644
index 00000000..6390e39d
--- /dev/null
+++ b/internal/upgrade/verify.go
@@ -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)
+}
diff --git a/internal/upgrade/verify_test.go b/internal/upgrade/verify_test.go
new file mode 100644
index 00000000..8e8e9851
--- /dev/null
+++ b/internal/upgrade/verify_test.go
@@ -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")
+ }
+}
diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go
new file mode 100644
index 00000000..deb0f1f9
--- /dev/null
+++ b/internal/upgrade/version.go
@@ -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
+}
diff --git a/internal/upgrade/version_test.go b/internal/upgrade/version_test.go
new file mode 100644
index 00000000..c39cd691
--- /dev/null
+++ b/internal/upgrade/version_test.go
@@ -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)
+ }
+ }
+}
diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go
new file mode 100644
index 00000000..dd2f44f9
--- /dev/null
+++ b/pkg/cli/cli.go
@@ -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()
+}
diff --git a/pkg/cmdutil/flagfix.go b/pkg/cmdutil/flagfix.go
new file mode 100644
index 00000000..610937a9
--- /dev/null
+++ b/pkg/cmdutil/flagfix.go
@@ -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]
+}
diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go
new file mode 100644
index 00000000..45e54027
--- /dev/null
+++ b/pkg/cmdutil/flags.go
@@ -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
+}
diff --git a/pkg/cmdutil/time.go b/pkg/cmdutil/time.go
new file mode 100644
index 00000000..90f7e4e7
--- /dev/null
+++ b/pkg/cmdutil/time.go
@@ -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
+}
diff --git a/internal/config/constants.go b/pkg/config/constants.go
similarity index 100%
rename from internal/config/constants.go
rename to pkg/config/constants.go
diff --git a/internal/config/constants_test.go b/pkg/config/constants_test.go
similarity index 100%
rename from internal/config/constants_test.go
rename to pkg/config/constants_test.go
diff --git a/internal/convert/slices.go b/pkg/convert/slices.go
similarity index 100%
rename from internal/convert/slices.go
rename to pkg/convert/slices.go
diff --git a/internal/convert/slices_test.go b/pkg/convert/slices_test.go
similarity index 100%
rename from internal/convert/slices_test.go
rename to pkg/convert/slices_test.go
diff --git a/pkg/edition/default.go b/pkg/edition/default.go
new file mode 100644
index 00000000..682d9686
--- /dev/null
+++ b/pkg/edition/default.go
@@ -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",
+ }
+}
diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go
new file mode 100644
index 00000000..54466514
--- /dev/null
+++ b/pkg/edition/edition.go
@@ -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
+}
diff --git a/pkg/editiontest/contract.go b/pkg/editiontest/contract.go
new file mode 100644
index 00000000..cc43f11d
--- /dev/null
+++ b/pkg/editiontest/contract.go
@@ -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")
+ }
+ })
+}
diff --git a/pkg/editiontest/contract_test.go b/pkg/editiontest/contract_test.go
new file mode 100644
index 00000000..2ec86a0c
--- /dev/null
+++ b/pkg/editiontest/contract_test.go
@@ -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())
+}
diff --git a/internal/validate/input.go b/pkg/validate/input.go
similarity index 100%
rename from internal/validate/input.go
rename to pkg/validate/input.go
diff --git a/internal/validate/input_test.go b/pkg/validate/input_test.go
similarity index 100%
rename from internal/validate/input_test.go
rename to pkg/validate/input_test.go
diff --git a/internal/validate/resource.go b/pkg/validate/resource.go
similarity index 100%
rename from internal/validate/resource.go
rename to pkg/validate/resource.go
diff --git a/internal/validate/resource_test.go b/pkg/validate/resource_test.go
similarity index 100%
rename from internal/validate/resource_test.go
rename to pkg/validate/resource_test.go
diff --git a/internal/validate/sanitize.go b/pkg/validate/sanitize.go
similarity index 100%
rename from internal/validate/sanitize.go
rename to pkg/validate/sanitize.go
diff --git a/internal/validate/sanitize_test.go b/pkg/validate/sanitize_test.go
similarity index 100%
rename from internal/validate/sanitize_test.go
rename to pkg/validate/sanitize_test.go
diff --git a/scripts/dev/test-release.sh b/scripts/dev/test-release.sh
new file mode 100644
index 00000000..1bd3ae08
--- /dev/null
+++ b/scripts/dev/test-release.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+set -e
+
+VERSION="v1.0.6"
+
+echo "==> Running GoReleaser snapshot..."
+goreleaser release --snapshot --clean
+
+echo "==> Running post-goreleaser.sh..."
+DWS_PACKAGE_VERSION=$VERSION ./scripts/release/post-goreleaser.sh
+
+echo "==> Verifying artifacts..."
+echo "Binary version:"
+./dist/dws-darwin-arm64/dws version 2>/dev/null || ./dist/dws-linux-amd64/dws version
+
+echo "npm package.json:"
+cat dist/npm/dingtalk-workspace-cli/package.json | grep '"version"'
+
+echo "dws-skills.zip:"
+ls -lh dist/dws-skills.zip
+
+echo "==> npm publish dry-run..."
+cd dist/npm/dingtalk-workspace-cli
+npm publish --access public --dry-run
+
+echo "==> All checks passed!"
\ No newline at end of file
diff --git a/scripts/release/post-goreleaser.sh b/scripts/release/post-goreleaser.sh
index 57a6a88a..6254ae29 100755
--- a/scripts/release/post-goreleaser.sh
+++ b/scripts/release/post-goreleaser.sh
@@ -53,14 +53,23 @@ detect_arch() {
}
resolve_version() {
+ # Priority 1: Use DWS_PACKAGE_VERSION environment variable (set by CI)
if [ -n "$PACKAGE_VERSION" ]; then
- printf '%s\n' "$PACKAGE_VERSION"
+ # Strip leading 'v' if present for semver compatibility
+ printf '%s\n' "$PACKAGE_VERSION" | sed 's/^v//'
return
fi
+ # Priority 2: Get version from git tag (for local snapshot builds with tag)
+ if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
+ git describe --tags --exact-match HEAD | sed 's/^v//'
+ return
+ fi
+
+ # Priority 3: Read from version.go (for local development without tag)
version_line="$(sed -n 's/^var version = "v\{0,1\}\([^"]*\)".*/\1/p' "$ROOT/internal/app/version.go" | head -1)"
- if [ -z "$version_line" ]; then
- err "could not resolve package version from internal/app/version.go"
+ if [ -z "$version_line" ] || [ "$version_line" = "dev" ]; then
+ err "could not resolve package version - set DWS_PACKAGE_VERSION or create a git tag"
fi
printf '%s\n' "$version_line"
}
diff --git a/test/cli/root_command_test.go b/test/cli/root_command_test.go
index 52f77304..ad4632c5 100644
--- a/test/cli/root_command_test.go
+++ b/test/cli/root_command_test.go
@@ -32,16 +32,21 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) {
}
}
-func TestSkillCommandIsNotRegisteredInPublicOSSBuild(t *testing.T) {
+func TestSkillCommandIsRegistered(t *testing.T) {
t.Parallel()
root := app.NewRootCommand()
+ var found bool
for _, cmd := range root.Commands() {
if cmd.Name() == "skill" {
- t.Fatalf("skill command should not be registered in OSS build")
+ found = true
+ break
}
}
+ if !found {
+ t.Fatalf("skill command should be registered")
+ }
}
// TestSchemaJSONBootstrapOutput was removed
diff --git a/test/unit/open_source_policy_test.go b/test/unit/open_source_policy_test.go
index e3507f9a..b6c3ab1d 100644
--- a/test/unit/open_source_policy_test.go
+++ b/test/unit/open_source_policy_test.go
@@ -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