Compare commits

..
1 Commits
Author SHA1 Message Date
tianlei.qjb f10a0599d2 chore: prepare release v1.0.5 2026-04-01 10:48:04 +08:00
85 changed files with 520 additions and 7563 deletions
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.6%"><title>coverage: 52.6%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.6%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.6%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

-38
View File
@@ -141,41 +141,3 @@ 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
+1 -2
View File
@@ -33,8 +33,7 @@ jobs:
title: issue.title,
body: issue.body,
state: issue.state,
html_url: issue.html_url,
labels: (issue.labels || []).map(label => label.name)
html_url: issue.html_url
}
};
+1 -2
View File
@@ -28,14 +28,13 @@ jobs:
const title = `[${action.toUpperCase()}] Issue #${issue.number}: ${issue.title}`;
const content = issue.body?.substring(0, 500) || 'No description';
const url = issue.html_url;
const labelsText = (issue.labels || []).map(label => label.name).join(', ') || '无标签';
// 消息内容必须包含关键字(如 "issue")以支持 Custom Keywords 安全模式
const message = {
msgtype: 'markdown',
markdown: {
title: 'GitHub Issue 通知',
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n🏷️ **Labels**: ${labelsText}\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
text: `## 🔔 GitHub Issue 通知\n\n**${title}**\n\n${content}${content.length >= 500 ? '...' : ''}\n\n[点击查看详情](${url})\n\n---\n📦 ${context.repo.owner}/${context.repo.repo}\n\n**关键词**: issue`
}
};
+3 -5
View File
@@ -37,14 +37,12 @@ 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 }}
- name: Upload skills package to release
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
-1
View File
@@ -27,4 +27,3 @@ test/cli_compat/testdata/
credentials*
plans
_docs
dws.zip
+1 -4
View File
@@ -1,6 +1,6 @@
GO ?= go
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test lint fmt policy package release publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -34,9 +34,6 @@ 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
+70 -75
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.5-red" alt="v1.0.5"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **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.
> **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.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
@@ -28,7 +28,6 @@
- [Why dws?](#why-dws)
- [Installation](#installation)
- [Upgrade](#upgrade)
- [Getting Started](#getting-started)
- [Quick Start](#quick-start)
- [Using with Agents](#using-with-agents)
@@ -86,91 +85,80 @@ cp dws ~/.local/bin/ # install to PATH
</details>
## Upgrade
dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
```bash
dws upgrade # interactive upgrade to latest version
dws upgrade --check # check for new versions without installing
dws upgrade --list # list all available versions
dws upgrade --version v1.0.7 # upgrade to a specific version
dws upgrade --rollback # rollback to the previous version
dws upgrade -y # skip confirmation prompt
```
<details>
<summary><strong>How it works</strong></summary>
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
| Flag | Description |
|------|-------------|
| `--check` | Check for updates without installing |
| `--list` | List all available versions with changelogs |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
| `--rollback` | Rollback to the previous backed-up version |
| `--force` | Force reinstall even if already on the latest version |
| `--skip-skills` | Skip skill package update |
| `-y` | Skip confirmation prompt |
</details>
## Getting Started
```bash
dws auth login # browser opens automatically
dws auth login --device # for headless environments (Docker, SSH, CI)
### 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**.
<details>
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="Create Application" width="600">
</p>
</details>
### 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
```
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`.
> `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.
<details>
<summary><strong>Organization hasn't enabled CLI access?</strong></summary>
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`
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="Apply for Access" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="Configure Redirect URL" width="600">
</p>
</details>
### Step 3: Publish the Application
Click "App Release - Version Management & Release" to publish and go live.
<details>
<summary><strong>Admin: Enable CLI access for your organization</strong></summary>
Go to [Developer Platform](https://open-dev.dingtalk.com) → "CLI Access Management" → Enable.
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI Access Management" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="Publish Application" width="600">
</p>
</details>
<details>
<summary><strong>Custom App mode (CI/CD, ISV integration)</strong></summary>
### Step 4: Request Whitelist Access
For enterprise-managed scenarios, create your own DingTalk app:
Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
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:
### Step 5: Authenticate
```bash
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
```
Credentials are securely persisted after first login (Keychain). Subsequent runs auto-refresh tokens.
Or via environment variables:
```bash
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
<details>
<summary><strong>Credential Configuration Priority</strong></summary>
`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
</details>
@@ -187,6 +175,13 @@ 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=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
### Agent Invocation Patterns
```bash
@@ -339,22 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## Key Services
| 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 |
| 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 |
| 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` | 2 | `message` | Send/recall DING messages |
| Ding | `ding` | 3 | `message` | Send/recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
| AITable | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, views, import/export, templates |
| Workbench | `workbench` | 2 | `app` | Batch query app details |
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
| DevDoc | `devdoc` | 2 | `article` | Search platform docs and error codes |
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
> 104 tools across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
<details>
<summary>Coming soon</summary>
+69 -74
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.5-red" alt="v1.0.5"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
@@ -28,7 +28,6 @@
- [为什么选择 dws?](#why-dws)
- [安装](#安装)
- [升级](#升级)
- [开始使用](#开始使用)
- [快速开始](#快速开始)
- [在 Agent 中使用](#在-agent-中使用)
@@ -86,91 +85,80 @@ cp dws ~/.local/bin/ # 安装到 PATH
</details>
## 升级
dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
```bash
dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出所有可用版本
dws upgrade --version v1.0.7 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级
```
<details>
<summary><strong>工作原理</strong></summary>
升级过程采用两阶段原子流程,确保一致性:
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
| Flag | 说明 |
|------|------|
| `--check` | 仅检查更新,不安装 |
| `--list` | 列出所有可用版本及更新日志 |
| `--version` | 升级到指定版本(如 `v1.0.7`) |
| `--rollback` | 回滚到上一个备份版本 |
| `--force` | 强制重新安装,即使已是最新版本 |
| `--skip-skills` | 跳过技能包更新 |
| `-y` | 跳过确认提示 |
</details>
## 开始使用
```bash
dws auth login # 自动唤起浏览器
dws auth login --device # 无浏览器环境(Docker、SSH、CI)
### 步骤 1:创建钉钉应用
进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。
<details>
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="创建应用" width="600">
</p>
</details>
### 步骤 2:配置重定向 URL
进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
```
http://127.0.0.1,https://login.dingtalk.com
```
选择组织并授权即可。
> 如果组织尚未开启 CLI 访问权限,系统会引导你向管理员发送申请。审批通过后重新执行 `dws auth login` 即可。
> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
<details>
<summary><strong>组织未开启 CLI 访问权限?</strong></summary>
1. 选择组织后,点击「立即申请」通知管理员
2. 管理员收到申请卡片,一键审批
3. 审批通过后,重新执行 `dws auth login`
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="申请权限" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="配置重定向URL" width="600">
</p>
</details>
### 步骤 3:发布应用
点击「应用发布 - 版本管理与发布」,发布版本使应用上线。
<details>
<summary><strong>管理员:为组织开启 CLI 访问权限</strong></summary>
进入 [开发者平台](https://open-dev.dingtalk.com) →「CLI 访问管理」→ 开启。
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI访问管理" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="发布应用" width="600">
</p>
</details>
<details>
<summary><strong>自建应用模式(CI/CD、ISV 集成)</strong></summary>
### 步骤 4:申请白名单
企业自主管控场景,可创建自有钉钉应用:
加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。
1. [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app#/corp/app) → 创建应用
2. 安全设置 → 添加重定向 URL:`http://127.0.0.1,https://login.dingtalk.com`
3. 发布应用
4. 登录:
### 步骤 5:登录认证
```bash
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
```
首次登录后凭证安全存储(Keychain),后续自动刷新 Token。
或通过环境变量:
```bash
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
<details>
<summary><strong>凭证配置优先级</strong></summary>
`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 环境**:建议使用环境变量配置
</details>
@@ -187,6 +175,13 @@ dws todo task list --dry-run # 预览操作但不执行
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
```bash
# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录)
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
### Agent 调用模式
```bash
@@ -339,22 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 核心服务
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
| 服务 | 命令 | 工具数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
| 通讯录 | `contact` | 8 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、话题回复、以用户身份发消息 |
| 机器人 | `chat bot` | 9 | — | 机器人创建、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
| DING | `ding` | 3 | `message` | 发送/撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
| 智能表格 | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、视图、导入导出、模板 |
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
| 开发者文档 | `devdoc` | 2 | `article` | 搜索开放平台文档与错误码 |
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
> 12 个产品,104 个工具。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
<details>
<summary>即将推出</summary>
+1 -1
View File
@@ -5,7 +5,6 @@ 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
@@ -16,6 +15,7 @@ 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
+5 -26
View File
@@ -24,9 +24,8 @@ 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"
)
@@ -49,9 +48,7 @@ func buildAuthCommand() *cobra.Command {
},
}
if !edition.Get().HideAuthLogin {
cmd.AddCommand(newAuthLoginCommand())
}
cmd.AddCommand(newAuthLoginCommand())
cmd.AddCommand(
newAuthLogoutCommand(),
newAuthStatusCommand(),
@@ -188,19 +185,9 @@ 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"))
@@ -209,9 +196,7 @@ func newAuthLogoutCommand() *cobra.Command {
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
}
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
return nil
},
}
@@ -241,8 +226,6 @@ func newAuthStatusCommand() *cobra.Command {
tokenData = updatedData
refreshed = true
}
} else if edition.Get().AutoPurgeToken {
_ = authpkg.DeleteTokenData(configDir)
}
}
if authStatusAuthenticated(tokenData) {
@@ -270,9 +253,7 @@ func newAuthStatusCommand() *cobra.Command {
}
} else {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "运行 dws auth login 进行登录")
}
fmt.Fprintln(w, "运行 dws auth login 进行登录")
}
return nil
},
@@ -350,9 +331,7 @@ func newAuthResetCommand() *cobra.Command {
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 认证信息已重置")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
}
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
return nil
},
}
+1 -5
View File
@@ -16,8 +16,6 @@ package app
import (
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Build-time variables injected via ldflags when available.
@@ -30,9 +28,7 @@ func defaultConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
if fn := edition.Get().ConfigDir; fn != nil {
return fn()
}
homeDir, err := os.UserHomeDir()
if err != nil {
return exeRelativeConfigDir()
+3 -3
View File
@@ -216,10 +216,10 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
}
}
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
func TestRootCommandDoesNotRegisterUpgradeCommand(t *testing.T) {
root := NewRootCommand()
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
t.Fatal("upgrade command should be registered on root, but was not found")
if cmd := lookupCommand(root, "upgrade"); cmd != nil {
t.Fatalf("findCommand(upgrade) = %q, want nil", cmd.CommandPath())
}
}
+2 -31
View File
@@ -30,25 +30,16 @@ 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...)
}
@@ -56,26 +47,6 @@ 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;
+1 -1
View File
@@ -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"}`, "--token", "test-token"}
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`}
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
+19 -94
View File
@@ -29,6 +29,7 @@ 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"
@@ -40,8 +41,6 @@ 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"
)
@@ -53,9 +52,7 @@ 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))
}
@@ -64,17 +61,12 @@ func Execute() int {
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)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", time.Since(initStart))
}
// Run PreParse handlers on raw argv before Cobra parses flags.
@@ -84,10 +76,8 @@ func Execute() int {
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)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", time.Since(execStart))
}
if err != nil {
if executed == nil {
@@ -279,7 +269,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
newCacheCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
newUpgradeCommand(),
newVersionCommand(),
schemaCmd,
genSkillsCmd,
@@ -289,12 +278,6 @@ 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
@@ -473,51 +456,24 @@ func newVersionCommand() *cobra.Command {
Example: " dws version\n dws version --format json",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
wantJSON := cmd.Flags().Changed("format")
if wantJSON {
format, _ := cmd.Flags().GetString("format")
wantJSON = (format == "json")
format, err := cmd.Flags().GetString("format")
if err != nil {
return apperrors.NewInternal("failed to read format flag")
}
editionName := edition.Get().Name
if editionName == "" {
editionName = "open"
payload := map[string]any{
"version": Version(),
"go": "1.24+",
}
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
}
if format == "json" {
return output.WriteJSON(cmd.OutOrStdout(), payload)
}
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
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"版本: %s\nGo: %s\n",
Version(),
"1.24+",
)
return err
},
}
}
@@ -614,30 +570,17 @@ 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
// or listed in the edition's VisibleProducts hook.
// unless they correspond to a product discovered via dynamic server discovery.
// Public utility commands (auth, cache, completion, version) are always kept
// visible; explicitly hidden commands stay hidden.
func hideNonDirectRuntimeCommands(root *cobra.Command) {
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()
}
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()
@@ -654,24 +597,6 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
}
}
// deduplicateCommands removes duplicate top-level commands, keeping the last
// registered one. This ensures overlay commands take precedence over
// open-source defaults when both register the same product name.
func deduplicateCommands(root *cobra.Command) {
seen := make(map[string]*cobra.Command)
var dups []*cobra.Command
for _, cmd := range root.Commands() {
name := cmd.Name()
if prev, ok := seen[name]; ok {
dups = append(dups, prev)
}
seen[name] = cmd
}
for _, dup := range dups {
root.RemoveCommand(dup)
}
}
func cacheStoreFromEnv() *cache.Store {
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
return cache.NewStore(cacheDir)
+4 -4
View File
@@ -172,8 +172,8 @@ func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
if err := root.Execute(); err != nil {
t.Fatalf("Execute(version) error = %v", err)
}
if !strings.Contains(out.String(), "Version:") {
t.Fatalf("version output missing Version line:\n%s", out.String())
if !strings.Contains(out.String(), "\"version\"") {
t.Fatalf("version output missing version key:\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 line:\n%s", out.String())
if !strings.Contains(out.String(), "\"version\"") {
t.Fatalf("version output missing version key:\n%s", out.String())
}
}
+1 -11
View File
@@ -5,7 +5,6 @@ import (
"strings"
"text/tabwriter"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -54,16 +53,7 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
return nil
}
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()
}
allowed := DirectRuntimeProductIDs()
if len(allowed) == 0 {
return nil
}
+4 -42
View File
@@ -31,7 +31,6 @@ 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 (
@@ -102,9 +101,7 @@ 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
}
@@ -135,11 +132,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
authStart := time.Now()
authToken := r.resolveAuthToken(ctx)
authDuration := time.Since(authStart)
RecordTiming(ctx, "auth_token", authDuration)
tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", authDuration)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", time.Since(authStart))
}
if invocation.DryRun {
@@ -171,32 +166,12 @@ 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)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", time.Since(callStart))
}
if err != nil {
if isAuthError(err) {
if fn := edition.Get().OnAuthError; fn != nil {
_ = fn(defaultConfigDir(), err)
}
}
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
@@ -272,10 +247,8 @@ 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)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", time.Since(loadStart))
}
}()
@@ -341,14 +314,6 @@ 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))
@@ -379,9 +344,6 @@ func resolveIdentityHeaders() map[string]string {
headers[k] = v
}
}
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
return headers
}
+6 -37
View File
@@ -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"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
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"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
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", "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -255,37 +255,6 @@ 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()
@@ -446,7 +415,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"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -496,7 +465,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"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -659,7 +628,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"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
err := cmd.Execute()
if err == nil {
-177
View File
@@ -1,177 +0,0 @@
// 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) != ""
}
-173
View File
@@ -1,173 +0,0 @@
// 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")
}
}
-104
View File
@@ -1,104 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// toolCallerAdapter bridges executor.Runner to the public edition.ToolCaller
// interface so that private overlays can invoke MCP tools without importing
// internal packages.
type toolCallerAdapter struct {
runner executor.Runner
flags *GlobalFlags
}
func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller {
return &toolCallerAdapter{runner: runner, flags: flags}
}
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
result, err := a.runner.Run(ctx, inv)
if err != nil {
return nil, err
}
return convertResult(result), nil
}
func (a *toolCallerAdapter) Format() string {
if a.flags != nil {
return a.flags.Format
}
return "json"
}
func (a *toolCallerAdapter) DryRun() bool {
return a.flags != nil && a.flags.DryRun
}
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
return &edition.ToolResult{}
}
// The runtime runner stores MCP response content under "content".
contentRaw, ok := resp["content"]
if !ok {
// Dry-run or echo mode: serialize the whole response as text.
data, _ := json.Marshal(resp)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
}
// Content may be a []any of {type, text} blocks from the MCP response,
// or a single map for mock mode.
switch v := contentRaw.(type) {
case []any:
blocks := make([]edition.ContentBlock, 0, len(v))
for _, item := range v {
if m, ok := item.(map[string]any); ok {
blocks = append(blocks, edition.ContentBlock{
Type: strVal(m, "type"),
Text: strVal(m, "text"),
})
}
}
return &edition.ToolResult{Content: blocks}
case map[string]any:
data, _ := json.Marshal(v)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
default:
data, _ := json.Marshal(contentRaw)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
}
}
func strVal(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
-720
View File
@@ -1,720 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
var (
ugBold = color.New(color.Bold).SprintFunc()
ugGreen = color.New(color.FgGreen).SprintFunc()
ugYellow = color.New(color.FgYellow).SprintFunc()
ugRed = color.New(color.FgRed).SprintFunc()
ugCyan = color.New(color.FgCyan).SprintFunc()
ugDim = color.New(color.Faint).SprintFunc()
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
)
func newUpgradeCommand() *cobra.Command {
var (
flagCheck bool
flagList bool
flagVersion string
flagRollback bool
flagForce bool
flagSkipSkills bool
)
cmd := &cobra.Command{
Use: "upgrade",
Short: "升级 DWS CLI 到最新版本",
Long: `检查并升级 DWS CLI 到最新版本。
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
Example: ` dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出所有可用版本
dws upgrade --version v1.0.5 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
format := resolveUpgradeFormat(cmd)
if flagList {
return runUpgradeList(cmd, format)
}
if flagRollback {
return runUpgradeRollback(yes)
}
if flagCheck {
return runUpgradeCheck(cmd, format)
}
return runUpgrade(cmd.Context(), upgradeOptions{
targetVersion: flagVersion,
force: flagForce,
skipSkills: flagSkipSkills,
yes: yes,
})
},
}
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
cmd.Flags().BoolVar(&flagList, "list", false, "列出所有可用版本")
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
return cmd
}
type upgradeOptions struct {
targetVersion string
force bool
skipSkills bool
yes bool
}
// --- dws upgrade --check ---
func runUpgradeCheck(cmd *cobra.Command, format string) error {
client := upgrade.NewClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim("检查更新..."))
}
latest, err := client.FetchLatestRelease()
if err != nil {
return fmt.Errorf("检查更新失败: %w", err)
}
currentVer := version
needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version)
if format == "json" {
return writeJSON(cmd.OutOrStdout(), map[string]any{
"current_version": ensureV(currentVer),
"latest_version": "v" + latest.Version,
"needs_upgrade": needsUpgrade,
"release_date": latest.Date,
"prerelease": latest.Prerelease,
"changelog": parseChangelogEntries(latest.Changelog, 10),
"release_url": latest.HTMLURL,
})
}
if !needsUpgrade {
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
return nil
}
fmt.Println()
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+latest.Version))
if latest.Date != "" {
fmt.Printf(" %s %s\n", ugBold("发布日期: "), latest.Date)
}
if latest.Prerelease {
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
}
if entries := parseChangelogEntries(latest.Changelog, 5); len(entries) > 0 {
fmt.Printf(" %s\n", ugBold("更新内容:"))
for _, e := range entries {
fmt.Printf(" %s %s\n", ugGreen("•"), e)
}
}
fmt.Println()
fmt.Printf(" %s\n", ugDim("运行 dws upgrade 进行升级"))
return nil
}
// --- dws upgrade --list ---
func runUpgradeList(cmd *cobra.Command, format string) error {
client := upgrade.NewClient()
if format != "json" {
fmt.Printf(" %s\n", ugDim("获取版本列表..."))
}
versions, err := client.FetchAllReleases()
if err != nil {
return fmt.Errorf("获取版本列表失败: %w", err)
}
currentVer := strings.TrimPrefix(version, "v")
if format == "json" {
var items []map[string]any
for _, v := range versions {
items = append(items, map[string]any{
"version": "v" + v.Version,
"date": v.Date,
"prerelease": v.Prerelease,
"installed": v.Version == currentVer,
"changelog": parseChangelogEntries(v.Changelog, 10),
})
}
return writeJSON(cmd.OutOrStdout(), map[string]any{
"current_version": ensureV(version),
"versions": items,
})
}
if len(versions) == 0 {
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
return nil
}
fmt.Println()
fmt.Printf(" %s\n", ugBold(fmt.Sprintf("%-12s %-12s %-12s %s", "VERSION", "DATE", "TYPE", "CHANGELOG")))
fmt.Printf(" %s\n", ugDim(strings.Repeat("─", 70)))
for _, v := range versions {
releaseType := ugGreen("stable")
if v.Prerelease {
releaseType = ugYellow("pre-release")
}
versionStr := fmt.Sprintf("v%-11s", v.Version)
marker := ""
if v.Version == currentVer {
versionStr = ugBoldGrn(versionStr)
marker = ugCyan(" ← 已安装")
}
changelog := ugDim(truncateChangelogForList(v.Changelog, 40))
fmt.Printf(" %s %-12s %-23s %s%s\n", versionStr, v.Date, releaseType, changelog, marker)
}
fmt.Println()
fmt.Printf(" %s %s\n", ugBold("当前版本:"), ugBoldGrn(ensureV(version)))
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.5 安装指定版本"))
return nil
}
// --- dws upgrade --rollback ---
func runUpgradeRollback(yes bool) error {
rm := upgrade.NewRollbackManager()
backups, err := rm.ListBackups()
if err != nil {
return fmt.Errorf("获取备份列表失败: %w", err)
}
if len(backups) == 0 {
return fmt.Errorf("没有可用的备份,无法回滚")
}
target := backups[0]
targetVer := ensureV(target.Version)
currentVer := ensureV(version)
fmt.Println()
fmt.Printf(" 当前版本: %s\n", ugBold(currentVer))
fmt.Printf(" 回滚目标: %s %s\n", ugCyan(targetVer), ugDim("("+target.CreatedAt.Format("2006-01-02 15:04")+")"))
if !yes {
fmt.Println()
fmt.Printf("是否回滚到 %s? [y/N] ", ugBold(targetVer))
var answer string
fmt.Scanln(&answer)
if answer != "y" && answer != "Y" {
fmt.Println("已取消")
return nil
}
}
fmt.Print(" 回滚中...")
if err := rm.RollbackTo(target); err != nil {
return fmt.Errorf("\n回滚失败: %w", err)
}
fmt.Printf(" %s\n", ugGreen("✓"))
fmt.Println()
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Printf(" %s 已回滚 %s %s %s\n", ugBoldGrn("✔"), ugDim(currentVer), ugBold("→"), ugBoldGrn(targetVer))
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Println()
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
return nil
}
// --- dws upgrade (full) ---
//
// The upgrade flow is split into two phases for atomicity:
// Phase 1 (Prepare): download, verify, extract — all in a temp directory, zero side effects.
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
// If anything fails in Phase 1, no files on disk are modified.
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugDim("检查更新..."))
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
return fmt.Errorf("初始化目录结构失败: %w", err)
}
upgrade.CleanupStaleFiles()
client := upgrade.NewClient()
var release *upgrade.ReleaseInfo
var err error
if opts.targetVersion != "" {
fmt.Printf(" 指定版本: %s\n", ugCyan("v"+opts.targetVersion))
release, err = client.FetchReleaseByTag(opts.targetVersion)
if err != nil {
return fmt.Errorf("获取版本 %s 信息失败: %w", opts.targetVersion, err)
}
} else {
release, err = client.FetchLatestRelease()
if err != nil {
return fmt.Errorf("检查更新失败: %w", err)
}
}
currentVer := version
if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) {
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
return nil
}
fmt.Println()
fmt.Printf(" %s %s %s %s\n", ugBold("新版本可用:"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
if release.Date != "" {
fmt.Printf(" %s %s\n", ugBold("发布日期: "), release.Date)
}
if release.Prerelease {
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
}
if !opts.yes {
fmt.Println()
fmt.Printf("是否升级? [y/N] ")
var answer string
fmt.Scanln(&answer)
if answer != "y" && answer != "Y" {
fmt.Println("已取消")
return nil
}
}
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
if err != nil {
tmpDir, err = os.MkdirTemp("", "dws-upgrade-*")
if err != nil {
return fmt.Errorf("创建临时目录失败: %w", err)
}
}
defer os.RemoveAll(tmpDir)
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
const totalSteps = 5
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
// ========================================================================
// Phase 1: Prepare (download + verify + extract — no side effects)
// ========================================================================
fmt.Println()
// --- Step 1: Backup ---
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
rm := upgrade.NewRollbackManager()
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
if backupErr != nil {
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
} else {
fmt.Printf(" %s\n", ugGreen("✓"))
}
// Fetch checksums.txt (needed for strict verification of both binary and skills)
var checksumsContent string
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
if checksumsAsset != nil {
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
checksumsContent = string(data)
}
}
}
// --- Step 2: Download (binary + skills together) ---
sl := stepFmt(2)
progressPrefix := fmt.Sprintf(" %s 下载 %s", sl, ugCyan(binaryAsset.Name))
fmt.Print(progressPrefix)
start := time.Now()
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
func(percent float64, downloaded, total int64) {
bar := progressBar(percent)
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
})
if err != nil {
fmt.Println()
return fmt.Errorf("下载二进制失败: %w", err)
}
elapsed := time.Since(start)
clearLine := strings.Repeat(" ", 100)
var skillsZipPath string
if hasSkills {
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包下载失败: %w", dlErr)
}
fmt.Printf(" %s\n", ugGreen("✓"))
} else {
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
}
// --- Step 3: Verify SHA256 (binary + skills together) ---
if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
return err
}
if hasSkills {
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
return err
}
}
// --- Step 4: Extract + validate ---
fmt.Printf(" %s 解压并验证...", stepFmt(4))
extractDir := filepath.Join(tmpDir, "extracted")
if strings.HasSuffix(binaryAsset.Name, ".zip") {
if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil {
fmt.Println()
return fmt.Errorf("解压失败: %w", err)
}
} else {
if err := extractTarGz(binaryArchivePath, extractDir); err != nil {
fmt.Println()
return fmt.Errorf("解压失败: %w", err)
}
}
binaryPath := upgrade.FindBinaryInDir(extractDir)
if binaryPath == "" {
fmt.Println()
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
}
if err := validateNewBinary(binaryPath, release.Version); err != nil {
fmt.Println()
return fmt.Errorf("验证失败: %w", err)
}
var skillSrc string
if hasSkills {
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
os.MkdirAll(skillsExtractDir, 0755)
if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil {
fmt.Println()
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
}
skillSrc = upgrade.LocateSkillMD(skillsExtractDir)
if skillSrc == "" {
fmt.Println()
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
}
}
fmt.Printf(" %s\n", ugGreen("✓"))
// ========================================================================
// Phase 2: Apply (all preparations succeeded — now do the actual changes)
// ========================================================================
// --- Step 5: Replace binary + install skills ---
fmt.Printf(" %s 替换并安装...", stepFmt(5))
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("替换二进制失败: %w", err)
}
if hasSkills {
result, installErr := upgrade.UpgradeSkillLocations(skillSrc)
if installErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包安装失败: %w", installErr)
}
failed := result.Failed()
if len(failed) > 0 {
fmt.Printf(" %s\n", ugRed("✗"))
for _, d := range failed {
fmt.Printf(" %s %s %s\n", ugRed("✗"), shortenHome(d.Dir), ugDim(d.Err.Error()))
}
return fmt.Errorf("技能包安装到 %d 个目录失败,请检查权限后手动重试: dws upgrade --force", len(failed))
}
succeeded := result.Succeeded()
fmt.Printf(" %s\n", ugGreen("✓"))
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("二进制已替换"))
fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim(fmt.Sprintf("技能包已安装 (%d 个位置)", len(succeeded))))
for _, d := range succeeded {
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
}
} else {
fmt.Printf(" %s\n", ugGreen("✓"))
}
// Cleanup old backups
rm.Cleanup(5)
// Summary
fmt.Println()
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Printf(" %s 升级完成 %s %s %s\n", ugBoldGrn("✔"), ugDim(ensureV(currentVer)), ugBold("→"), ugBoldGrn("v"+release.Version))
fmt.Printf(" %s\n", ugDim("──────────────────────────────────────"))
fmt.Println()
fmt.Printf(" %s\n", ugDim("运行 dws version 验证当前版本"))
fmt.Printf(" %s\n", ugDim("如遇问题,运行 dws upgrade --rollback 回滚"))
return nil
}
// strictVerifyFile performs SHA256 verification with strict semantics:
// - If checksum info is available and matches → ✓
// - If checksum info is available but MISMATCHES → error (abort upgrade)
// - If no checksum info at all → skip (no data to compare against)
func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent string) error {
fmt.Printf(" %s 校验 %s...", label, fileName)
// Source 1: checksums.txt
if checksumsContent != "" {
checksums := upgrade.ParseChecksumFile(checksumsContent)
if expectedHash, ok := checksums[fileName]; ok {
if err := upgrade.VerifySHA256(filePath, expectedHash); err != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
}
fmt.Printf(" %s\n", ugGreen("✓"))
return nil
}
}
// Source 2: GitHub asset digest
if digest := upgrade.ExtractDigestSHA256(assetDigest); digest != "" {
if err := upgrade.VerifySHA256(filePath, digest); err != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("SHA256 校验失败 (%s): %w\n 文件可能被篡改或下载不完整,请重试升级", fileName, err)
}
fmt.Printf(" %s\n", ugGreen("✓"))
return nil
}
// No checksum info available at all
fmt.Printf(" %s\n", ugDim("- 跳过 (无可用校验信息)"))
return nil
}
// validateNewBinary checks the downloaded binary is valid.
func validateNewBinary(binaryPath, expectedVersion string) error {
info, err := os.Stat(binaryPath)
if err != nil {
return fmt.Errorf("文件不存在: %w", err)
}
if info.Size() == 0 {
return fmt.Errorf("文件为空")
}
if err := os.Chmod(binaryPath, 0755); err != nil {
return fmt.Errorf("设置执行权限失败: %w", err)
}
// Try running the binary
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
if err != nil {
return fmt.Errorf("二进制无法执行: %w", err)
}
if !strings.Contains(string(out), expectedVersion) {
// Not fatal, version format might differ
fmt.Printf("\n 注意: 版本输出中未包含 %s", expectedVersion)
}
return nil
}
// extractTarGz extracts a .tar.gz file using the system tar command.
func extractTarGz(archivePath, destDir string) error {
os.MkdirAll(destDir, 0755)
cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("tar 解压失败: %v: %s", err, string(out))
}
return nil
}
func progressBar(percent float64) string {
width := 20
filled := int(percent / 100 * float64(width))
if filled > width {
filled = width
}
return strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
}
// parseChangelogEntries extracts human-readable commit messages from a
// GitHub Release body. The body typically looks like:
//
// ## Changelog
// * abcdef1234 - some commit message
// * 0123456789 Merge branch 'main' into main
//
// We strip the hash prefix and skip noisy entries (Merge branch, Merge pull request).
func parseChangelogEntries(body string, maxEntries int) []string {
var entries []string
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "* ")
line = strings.TrimPrefix(line, "- ")
msg := stripCommitHash(line)
if msg == "" {
continue
}
if isNoiseCommit(msg) {
continue
}
entries = append(entries, msg)
if maxEntries > 0 && len(entries) >= maxEntries {
break
}
}
return entries
}
// truncateChangelog returns a short one-line summary for the --check output.
func truncateChangelog(body string) string {
entries := parseChangelogEntries(body, 3)
if len(entries) == 0 {
return ""
}
return strings.Join(entries, "; ")
}
// truncateChangelogForList returns a compact summary for the --list table.
func truncateChangelogForList(body string, maxLen int) string {
entries := parseChangelogEntries(body, 2)
if len(entries) == 0 {
return "-"
}
summary := strings.Join(entries, "; ")
if len(summary) > maxLen {
return summary[:maxLen-3] + "..."
}
return summary
}
// stripCommitHash removes a leading Git commit hash (7-40 hex chars)
// and optional separator (" - ", " ") from a line.
func stripCommitHash(line string) string {
if len(line) < 8 {
return line
}
// Check if line starts with hex chars (commit hash)
hashEnd := 0
for hashEnd < len(line) && hashEnd < 40 {
c := line[hashEnd]
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
hashEnd++
} else {
break
}
}
if hashEnd < 7 {
return line
}
rest := line[hashEnd:]
rest = strings.TrimPrefix(rest, " - ")
rest = strings.TrimLeft(rest, " ")
return rest
}
func isNoiseCommit(msg string) bool {
lower := strings.ToLower(msg)
noisePatterns := []string{
"merge branch",
"merge pull request",
"merge remote-tracking",
}
for _, p := range noisePatterns {
if strings.HasPrefix(lower, p) {
return true
}
}
return false
}
// ensureV ensures a version string has a "v" prefix for display consistency.
// Non-semver values like "dev" or "unknown" are returned as-is.
func ensureV(ver string) string {
if ver == "" {
return "v0.0.0"
}
if strings.HasPrefix(ver, "v") {
return ver
}
// Only add "v" prefix for semver-like strings (starts with digit)
if len(ver) > 0 && ver[0] >= '0' && ver[0] <= '9' {
return "v" + ver
}
return ver
}
// resolveUpgradeFormat returns "json" only when the user explicitly passes -f json.
// Unlike other commands, upgrade defaults to table (human-friendly) output.
func resolveUpgradeFormat(cmd *cobra.Command) string {
pf := cmd.Root().PersistentFlags()
if pf.Changed("format") {
if f, err := pf.GetString("format"); err == nil {
return strings.ToLower(strings.TrimSpace(f))
}
}
return "table"
}
func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}
func shortenHome(path string) string {
homeDir, err := os.UserHomeDir()
if err != nil {
return path
}
if strings.HasPrefix(path, homeDir) {
return "~" + path[len(homeDir):]
}
return path
}
-430
View File
@@ -1,430 +0,0 @@
// 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")
}
}
+1 -25
View File
@@ -13,22 +13,7 @@
package app
var version = "dev"
// SetVersion overrides the version, build time and git commit strings.
// Called by pkg/cli.SetVersion for overlay modules that inject their own
// version info via ldflags.
func SetVersion(v, bt, gc string) {
if v != "" {
version = v
}
if bt != "" {
buildTime = bt
}
if gc != "" {
gitCommit = gc
}
}
var version = "v1.0.6"
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
@@ -38,12 +23,3 @@ func Version() string {
}
return version
}
// RawVersion returns the bare version string without build metadata.
func RawVersion() string { return version }
// BuildTime returns the build timestamp injected via ldflags.
func BuildTime() string { return buildTime }
// GitCommit returns the git commit hash injected via ldflags.
func GitCommit() string { return gitCommit }
-840
View File
@@ -1,840 +0,0 @@
// 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)
}
+6 -7
View File
@@ -26,8 +26,8 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/fatih/color"
)
@@ -167,15 +167,14 @@ 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)
// Check if CLI auth is enabled for this organization
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)
if p.logger != nil {
p.logger.Warn("failed to check CLI auth status", "error", authErr)
}
// Continue anyway - fail open for better UX
} else if authStatus.Success && !authStatus.Result.CLIAuthEnabled {
// CLI auth is disabled - show detailed error with admin info
_, _ = fmt.Fprintln(p.output(), "")
-21
View File
@@ -140,27 +140,6 @@ func GetRevokeTokenURL() string {
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()
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
)
const (
+3 -7
View File
@@ -25,8 +25,7 @@ import (
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
)
const identityFile = "identity.json"
@@ -83,11 +82,8 @@ func (id *Identity) Headers() map[string]string {
if id.Source != "" {
h["x-dws-source"] = id.Source
}
scenarioCode := "com.dingtalk.cli"
if sc := edition.Get().ScenarioCode; sc != "" {
scenarioCode = sc
}
h["x-dingtalk-scenario-code"] = scenarioCode
// Constant headers for MCP gateway tracking
h["x-dingtalk-scenario-code"] = "com.dingtalk.cli"
h["x-dingtalk-source"] = "github"
return h
}
-42
View File
@@ -105,45 +105,3 @@ 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)
}
+1 -1
View File
@@ -15,8 +15,8 @@ package auth
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"log/slog"
"os"
"path/filepath"
File diff suppressed because it is too large Load Diff
+28 -58
View File
@@ -131,11 +131,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Shared state for API handlers (protected by mutex)
var (
callbackToken *TokenData
callbackProcessedCode string // The auth code that has been successfully processed
callbackProcessed bool
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
)
@@ -147,48 +146,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
code = r.URL.Query().Get("code")
}
// Check state and handle page refresh or concurrent requests
// Check if this is a page refresh (no code) and callback was already processed
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()
if code == "" && callbackProcessed {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if processedAuthDisabled {
if callbackAuthDisabled {
_, _ = 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, `<html><head><meta http-equiv="refresh" content="1"></head><body><p>正在处理授权,请稍候...</p></body></html>`)
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
// Reset state for new authorization (user switched org)
if code != "" && callbackProcessed {
callbackProcessed = false
callbackApplySent = false
callbackSelectedAdminId = ""
}
callbackTokenMu.Unlock()
@@ -202,13 +176,20 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return
}
// Exchange code for token
// Exchange code for token immediately in callback
tokenData, exchangeErr := p.exchangeCode(ctx, code)
if exchangeErr != nil {
// Clear in-progress state on error
// Check if we already have a processed state (authCode reused on refresh)
callbackTokenMu.Lock()
if callbackCodeInProgress == code {
callbackCodeInProgress = ""
if callbackProcessed {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if callbackAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
callbackTokenMu.Unlock()
return
}
callbackTokenMu.Unlock()
@@ -221,31 +202,20 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
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)
// Check CLI auth enabled status
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled
cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled
// Update CLI auth disabled state
// Store token and state for API handlers and refresh handling
callbackTokenMu.Lock()
callbackAuthDisabled = !cliAuthEnabled
callbackToken = tokenData
callbackProcessed = true
callbackAuthDisabled = cliAuthDisabled
callbackTokenMu.Unlock()
// Display appropriate HTML based on CLI auth status
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if !cliAuthEnabled {
if cliAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
@@ -256,7 +226,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Notify main goroutine with full result
select {
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}:
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: cliAuthDisabled}:
default:
}
})
+1 -1
View File
@@ -21,8 +21,8 @@ import (
"path/filepath"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/security"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const secureDataFile = ".data"
+1 -1
View File
@@ -25,12 +25,12 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -22,11 +22,11 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
+1 -1
View File
@@ -26,10 +26,10 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -26,9 +26,9 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
+34 -4
View File
@@ -14,14 +14,15 @@
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"
)
@@ -87,7 +88,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
title := cmdutil.FlagOrFallback(cmd, "title", "subject", "content")
title := flagOrFallback(cmd, "title", "subject", "content")
if strings.TrimSpace(title) == "" {
return apperrors.NewValidation("--title is required")
}
@@ -102,7 +103,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
"executorIds": executorIds,
}
if v, _ := cmd.Flags().GetString("due"); v != "" {
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
ms, err := parseISOTimeToMillis("due", v)
if err != nil {
return err
}
@@ -269,7 +270,7 @@ func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command {
inner["subject"] = v
}
if v, _ := cmd.Flags().GetString("due"); v != "" {
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
ms, err := parseISOTimeToMillis("due", v)
if err != nil {
return err
}
@@ -443,6 +444,16 @@ 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)
@@ -459,6 +470,25 @@ func parseExecutorIds(s string) []string {
return ids
}
// parseISOTimeToMillis parses an ISO-8601 datetime string and returns Unix
// milliseconds. It supports timezone offsets (e.g. +08:00) and UTC "Z" suffix.
func parseISOTimeToMillis(flagName, value string) (int64, error) {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
}
for _, layout := range formats {
if t, err := time.Parse(layout, value); err == nil {
return t.UnixMilli(), nil
}
}
return 0, apperrors.NewValidation(
fmt.Sprintf("--%s format error, use ISO-8601 e.g. 2026-03-10T18:00:00+08:00", flagName),
)
}
// ── list pagination helpers ────────────────────────────────
func normalizePage(raw string) string {
-15
View File
@@ -155,20 +155,5 @@
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
"附件工作流": "Attachment workflow",
"页码 (必填)": "page number (required)",
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
" 请检查网络连接后重试。": " Please check your network connection and retry.",
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
" 组织主管理员:": " Organization super admins: ",
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " Admin settings: https://open-dev.dingtalk.com/fe/old#/developerSettings",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
"等待管理员审批中": "Waiting for admin approval",
"等待提交申请中": "Waiting to submit request",
"操作超时,请重新登录": "Operation timed out, please re-login",
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
"🔐 登录钉钉": "🔐 Login to DingTalk"
}
-15
View File
@@ -155,20 +155,5 @@
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
"附件工作流": "附件工作流",
"页码 (必填)": "页码 (必填)",
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
" 组织主管理员:": " 组织主管理员:",
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings": " 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
"等待管理员审批中": "等待管理员审批中",
"等待提交申请中": "等待提交申请中",
"操作超时,请重新登录": "操作超时,请重新登录",
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
"🔐 登录钉钉": "🔐 登录钉钉"
}
+1 -1
View File
@@ -24,7 +24,7 @@ import (
"strconv"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
)
const (
+1 -1
View File
@@ -26,8 +26,8 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
+1 -1
View File
@@ -13,7 +13,7 @@
package output
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
// SanitizeForTerminal strips ANSI escape sequences, control characters, and
// dangerous Unicode from text before it is printed to a terminal.
+46 -2
View File
@@ -17,7 +17,6 @@ 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
@@ -102,7 +101,7 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
ambiguous := false
for _, candidate := range candidates {
dist := cmdutil.LevenshteinDist(bare, candidate)
dist := levenshtein(bare, candidate)
if dist < bestDist {
bestDist = dist
bestMatch = candidate
@@ -118,3 +117,48 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
return "--" + bestMatch + suffix, true
}
// levenshtein computes the edit distance between two strings using
// the standard dynamic programming approach with O(min(m,n)) space.
func levenshtein(a, b string) int {
if a == b {
return 0
}
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
// Ensure a is the shorter string for O(min) space.
if la > lb {
a, b = b, a
la, lb = lb, la
}
prev := make([]int, la+1)
curr := make([]int, la+1)
for i := range prev {
prev[i] = i
}
for j := 1; j <= lb; j++ {
curr[0] = j
for i := 1; i <= la; i++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[i] = min(
prev[i]+1, // deletion
curr[i-1]+1, // insertion
prev[i-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[la]
}
+4 -4
View File
@@ -18,7 +18,6 @@ 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) {
@@ -41,11 +40,12 @@ func TestLevenshtein(t *testing.T) {
for _, tt := range tests {
t.Run(tt.a+"→"+tt.b, func(t *testing.T) {
got := cmdutil.LevenshteinDist(tt.a, tt.b)
got := levenshtein(tt.a, tt.b)
if got != tt.want {
t.Errorf("LevenshteinDist(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
gotRev := cmdutil.LevenshteinDist(tt.b, tt.a)
// Verify symmetry.
gotRev := levenshtein(tt.b, tt.a)
if gotRev != got {
t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev)
}
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"path/filepath"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
)
// DataFileName is the encrypted token data file name.
+2 -2
View File
@@ -30,11 +30,11 @@ import (
"log/slog"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
)
const (
-185
View File
@@ -1,185 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// DownloadConfig holds download configuration.
type DownloadConfig struct {
MaxRetries int
Timeout time.Duration
ProgressCallback func(downloaded, total int64)
ProgressInterval time.Duration
}
// DefaultDownloadConfig returns sensible download defaults.
func DefaultDownloadConfig() *DownloadConfig {
return &DownloadConfig{
MaxRetries: 3,
Timeout: 10 * time.Minute,
ProgressInterval: 200 * time.Millisecond,
}
}
// Download fetches url to destPath with default config.
func Download(url, destPath string) (int64, error) {
return DownloadWithConfig(context.Background(), url, destPath, DefaultDownloadConfig())
}
// DownloadWithProgress downloads a file and reports progress.
func DownloadWithProgress(ctx context.Context, url, destPath string, showProgress func(percent float64, downloaded, total int64)) (int64, error) {
cfg := DefaultDownloadConfig()
cfg.ProgressCallback = func(downloaded, total int64) {
if showProgress != nil && total > 0 {
percent := float64(downloaded) / float64(total) * 100
showProgress(percent, downloaded, total)
}
}
return DownloadWithConfig(ctx, url, destPath, cfg)
}
// DownloadWithConfig fetches a file with custom configuration and retries.
func DownloadWithConfig(ctx context.Context, url, destPath string, cfg *DownloadConfig) (int64, error) {
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return 0, fmt.Errorf("创建目录失败: %w", err)
}
maxRetries := cfg.MaxRetries
if maxRetries < 1 {
maxRetries = 1
}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Second * time.Duration(1<<uint(attempt-1))
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-time.After(backoff):
}
}
n, err := doDownload(ctx, url, destPath, cfg)
if err == nil {
return n, nil
}
lastErr = err
if ctx.Err() != nil {
return 0, ctx.Err()
}
if !isRetriable(err) {
return 0, err
}
os.Remove(destPath)
}
return 0, fmt.Errorf("下载失败 (重试 %d 次后): %w", maxRetries, lastErr)
}
func doDownload(ctx context.Context, url, destPath string, cfg *DownloadConfig) (int64, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return 0, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("User-Agent", userAgent)
client := &http.Client{}
if cfg.Timeout > 0 {
client.Timeout = cfg.Timeout
}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("下载失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("下载失败 (HTTP %d): %s", resp.StatusCode, url)
}
out, err := os.Create(destPath)
if err != nil {
return 0, fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
var writer io.Writer = out
if cfg.ProgressCallback != nil {
pw := &progressWriter{
writer: out,
total: resp.ContentLength,
callback: cfg.ProgressCallback,
interval: cfg.ProgressInterval,
lastCallback: time.Now(),
}
defer pw.finalProgress()
writer = pw
}
n, err := io.Copy(writer, resp.Body)
if err != nil {
return 0, fmt.Errorf("写入文件失败: %w", err)
}
return n, nil
}
// progressWriter wraps an io.Writer and reports download progress.
type progressWriter struct {
writer io.Writer
total int64
downloaded int64
callback func(downloaded, total int64)
interval time.Duration
lastCallback time.Time
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.downloaded += int64(n)
now := time.Now()
if pw.callback != nil && now.Sub(pw.lastCallback) >= pw.interval {
pw.callback(pw.downloaded, pw.total)
pw.lastCallback = now
}
return n, err
}
func (pw *progressWriter) finalProgress() {
if pw.callback != nil {
pw.callback(pw.downloaded, pw.total)
}
}
func isRetriable(err error) bool {
if err == nil {
return false
}
if os.IsTimeout(err) {
return true
}
errStr := err.Error()
for _, pattern := range []string{"connection reset", "connection refused", "timeout", "temporary failure", "eof"} {
if strings.Contains(strings.ToLower(errStr), pattern) {
return true
}
}
return false
}
-174
View File
@@ -1,174 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestDownload_Success(t *testing.T) {
body := "hello world binary content"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.Write([]byte(body))
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "downloaded")
n, err := Download(server.URL+"/file.tar.gz", dest)
if err != nil {
t.Fatalf("Download() error = %v", err)
}
if n != int64(len(body)) {
t.Errorf("Download() = %d bytes, want %d", n, len(body))
}
got, _ := os.ReadFile(dest)
if string(got) != body {
t.Errorf("file content = %q, want %q", string(got), body)
}
}
func TestDownload_HTTP404(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "notfound")
_, err := Download(server.URL+"/missing", dest)
if err == nil {
t.Fatal("Download() expected error for 404")
}
}
func TestDownload_HTTP500_RetriesAndFails(t *testing.T) {
var attempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "err")
cfg := &DownloadConfig{
MaxRetries: 2,
Timeout: 5 * time.Second,
}
_, err := DownloadWithConfig(context.Background(), server.URL+"/fail", dest, cfg)
if err == nil {
t.Fatal("expected error after retries")
}
// 500 is not retriable (no "connection reset" pattern), so only 1 attempt
got := atomic.LoadInt32(&attempts)
if got != 1 {
t.Errorf("attempts = %d, want 1 (500 is not retriable)", got)
}
}
func TestDownload_ContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("late"))
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
dest := filepath.Join(t.TempDir(), "cancelled")
_, err := DownloadWithProgress(ctx, server.URL+"/slow", dest, nil)
if err == nil {
t.Fatal("expected error for cancelled context")
}
}
func TestDownloadWithProgress_Callback(t *testing.T) {
body := make([]byte, 1024)
for i := range body {
body[i] = 'A'
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.Write(body)
}))
defer server.Close()
var called int32
dest := filepath.Join(t.TempDir(), "progress")
n, err := DownloadWithProgress(context.Background(), server.URL+"/file", dest,
func(percent float64, downloaded, total int64) {
atomic.AddInt32(&called, 1)
})
if err != nil {
t.Fatalf("DownloadWithProgress() error = %v", err)
}
if n != int64(len(body)) {
t.Errorf("bytes = %d, want %d", n, len(body))
}
if atomic.LoadInt32(&called) == 0 {
t.Error("progress callback was never called")
}
}
func TestDownload_CreatesParentDirectories(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("data"))
}))
defer server.Close()
dest := filepath.Join(t.TempDir(), "a", "b", "c", "file")
_, err := Download(server.URL+"/file", dest)
if err != nil {
t.Fatalf("Download() error = %v", err)
}
if _, err := os.Stat(dest); err != nil {
t.Errorf("file not created: %v", err)
}
}
func TestDefaultDownloadConfig(t *testing.T) {
cfg := DefaultDownloadConfig()
if cfg.MaxRetries != 3 {
t.Errorf("MaxRetries = %d, want 3", cfg.MaxRetries)
}
if cfg.Timeout != 10*time.Minute {
t.Errorf("Timeout = %v, want 10m", cfg.Timeout)
}
if cfg.ProgressInterval != 200*time.Millisecond {
t.Errorf("ProgressInterval = %v, want 200ms", cfg.ProgressInterval)
}
}
func TestIsRetriable(t *testing.T) {
tests := []struct {
err error
want bool
}{
{nil, false},
{fmt.Errorf("connection reset by peer"), true},
{fmt.Errorf("Connection Refused"), true},
{fmt.Errorf("request timeout exceeded"), true},
{fmt.Errorf("temporary failure in name resolution"), true},
{fmt.Errorf("unexpected EOF"), true},
{fmt.Errorf("permission denied"), false},
{fmt.Errorf("file not found"), false},
}
for _, tt := range tests {
got := isRetriable(tt.err)
if got != tt.want {
t.Errorf("isRetriable(%q) = %v, want %v", tt.err, got, tt.want)
}
}
}
-293
View File
@@ -1,293 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package upgrade provides self-update functionality for the DWS CLI
// using GitHub Releases as the data source.
package upgrade
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
"time"
)
const (
gitHubAPIBase = "https://api.github.com"
defaultOwner = "DingTalk-Real-AI"
defaultRepo = "dingtalk-workspace-cli"
httpTimeout = 30 * time.Second
userAgent = "DWS-CLI-Upgrade/1.0"
skillsZipName = "dws-skills.zip"
checksumsName = "checksums.txt"
)
// GitHubRelease represents a single release from the GitHub Releases API.
type GitHubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
PublishedAt string `json:"published_at"`
Assets []GitHubAsset `json:"assets"`
HTMLURL string `json:"html_url"`
}
// GitHubAsset represents a release asset (downloadable file).
type GitHubAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
Digest string `json:"digest"`
BrowserDownloadURL string `json:"browser_download_url"`
ContentType string `json:"content_type"`
}
// ReleaseInfo is the simplified view of a release used throughout the upgrade flow.
type ReleaseInfo struct {
Version string
Date string
Changelog string
Prerelease bool
HTMLURL string
Assets []GitHubAsset
}
// VersionEntry represents a single version in the version list.
type VersionEntry struct {
Version string
Date string
Changelog string
Prerelease bool
}
// Client communicates with the GitHub Releases API.
type Client struct {
httpClient *http.Client
owner string
repo string
baseURL string // overridable for testing or mirrors
}
// NewClient creates a GitHub release client with default settings.
func NewClient() *Client {
baseURL := gitHubAPIBase
if env := os.Getenv("DWS_UPGRADE_URL"); env != "" {
baseURL = strings.TrimRight(env, "/")
}
return &Client{
httpClient: &http.Client{Timeout: httpTimeout},
owner: defaultOwner,
repo: defaultRepo,
baseURL: baseURL,
}
}
// NewClientWithBaseURL creates a client with a custom base URL (for testing).
func NewClientWithBaseURL(baseURL string) *Client {
return &Client{
httpClient: &http.Client{Timeout: httpTimeout},
owner: defaultOwner,
repo: defaultRepo,
baseURL: strings.TrimRight(baseURL, "/"),
}
}
// FetchLatestRelease returns the latest non-draft release.
func (c *Client) FetchLatestRelease() (*ReleaseInfo, error) {
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", c.baseURL, c.owner, c.repo)
var gh GitHubRelease
if err := c.getJSON(url, &gh); err != nil {
return nil, fmt.Errorf("获取最新版本失败: %w", err)
}
return ghReleaseToInfo(&gh), nil
}
// FetchReleaseByTag returns the release for a specific tag (e.g. "v1.0.5").
func (c *Client) FetchReleaseByTag(tag string) (*ReleaseInfo, error) {
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", c.baseURL, c.owner, c.repo, tag)
var gh GitHubRelease
if err := c.getJSON(url, &gh); err != nil {
return nil, fmt.Errorf("获取版本 %s 失败: %w", tag, err)
}
return ghReleaseToInfo(&gh), nil
}
// FetchAllReleases returns all non-draft releases, newest first.
func (c *Client) FetchAllReleases() ([]VersionEntry, error) {
url := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=100", c.baseURL, c.owner, c.repo)
var ghReleases []GitHubRelease
if err := c.getJSON(url, &ghReleases); err != nil {
return nil, fmt.Errorf("获取版本列表失败: %w", err)
}
var versions []VersionEntry
for _, gh := range ghReleases {
if gh.Draft {
continue
}
versions = append(versions, VersionEntry{
Version: stripV(gh.TagName),
Date: formatDate(gh.PublishedAt),
Changelog: gh.Body,
Prerelease: gh.Prerelease,
})
}
return versions, nil
}
// FindBinaryAsset locates the platform-specific binary archive from the release assets.
// Pattern: dws-{os}-{arch}.tar.gz (or .zip for windows).
func FindBinaryAsset(assets []GitHubAsset) (*GitHubAsset, error) {
return FindBinaryAssetFor(assets, runtime.GOOS, runtime.GOARCH)
}
// FindBinaryAssetFor locates the binary archive for a specific platform.
func FindBinaryAssetFor(assets []GitHubAsset, goos, goarch string) (*GitHubAsset, error) {
ext := ".tar.gz"
if goos == "windows" {
ext = ".zip"
}
target := fmt.Sprintf("dws-%s-%s%s", goos, goarch, ext)
for i := range assets {
if assets[i].Name == target {
return &assets[i], nil
}
}
return nil, fmt.Errorf("当前平台 %s/%s 没有可用的预编译二进制 (需要 %s)", goos, goarch, target)
}
// FindSkillsAsset locates the dws-skills.zip asset.
func FindSkillsAsset(assets []GitHubAsset) *GitHubAsset {
for i := range assets {
if assets[i].Name == skillsZipName {
return &assets[i]
}
}
return nil
}
// FindChecksumsAsset locates the checksums.txt asset.
func FindChecksumsAsset(assets []GitHubAsset) *GitHubAsset {
for i := range assets {
if assets[i].Name == checksumsName {
return &assets[i]
}
}
return nil
}
// ExtractDigestSHA256 extracts the hex hash from a GitHub asset digest field.
// GitHub format: "sha256:abcdef1234..."
func ExtractDigestSHA256(digest string) string {
if strings.HasPrefix(digest, "sha256:") {
return digest[7:]
}
return ""
}
// getJSON performs a GET request and decodes the JSON response.
func (c *Client) getJSON(url string, target interface{}) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/vnd.github+json")
if token := githubToken(); token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("无法连接到 GitHub: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining == "0" {
return fmt.Errorf("GitHub API 请求频率超限。设置 GITHUB_TOKEN 或 GH_TOKEN 环境变量可提升限额")
}
}
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("未找到 (404)")
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GitHub API 返回 HTTP %d: %s", resp.StatusCode, truncateBytes(body, 200))
}
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
return fmt.Errorf("解析 GitHub 响应失败: %w", err)
}
return nil
}
// githubToken returns a GitHub token from environment if available.
func githubToken() string {
if t := os.Getenv("GITHUB_TOKEN"); t != "" {
return t
}
if t := os.Getenv("GH_TOKEN"); t != "" {
return t
}
return ""
}
func ghReleaseToInfo(gh *GitHubRelease) *ReleaseInfo {
return &ReleaseInfo{
Version: stripV(gh.TagName),
Date: formatDate(gh.PublishedAt),
Changelog: gh.Body,
Prerelease: gh.Prerelease,
HTMLURL: gh.HTMLURL,
Assets: gh.Assets,
}
}
func stripV(tag string) string {
return strings.TrimPrefix(tag, "v")
}
func formatDate(published string) string {
t, err := time.Parse(time.RFC3339, published)
if err != nil {
return published
}
return t.Format("2006-01-02")
}
func truncateBody(body string, maxLen int) string {
body = strings.TrimSpace(body)
lines := strings.SplitN(body, "\n", 2)
first := lines[0]
if len(first) > maxLen {
return first[:maxLen-3] + "..."
}
return first
}
func truncateBytes(b []byte, max int) string {
if len(b) > max {
return string(b[:max]) + "..."
}
return string(b)
}
-468
View File
@@ -1,468 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestFetchLatestRelease(t *testing.T) {
release := GitHubRelease{
TagName: "v1.0.6",
Name: "v1.0.6",
Body: "Bug fixes and improvements",
Prerelease: false,
Draft: false,
PublishedAt: "2026-04-01T09:29:05Z",
Assets: []GitHubAsset{
{Name: "dws-darwin-arm64.tar.gz", Size: 3942308, BrowserDownloadURL: "https://example.com/dws-darwin-arm64.tar.gz"},
{Name: "dws-skills.zip", Size: 50000, BrowserDownloadURL: "https://example.com/dws-skills.zip"},
{Name: "checksums.txt", Size: 534, BrowserDownloadURL: "https://example.com/checksums.txt"},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/latest" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(release)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
info, err := client.FetchLatestRelease()
if err != nil {
t.Fatalf("FetchLatestRelease() error = %v", err)
}
if info.Version != "1.0.6" {
t.Errorf("Version = %q, want %q", info.Version, "1.0.6")
}
if info.Date != "2026-04-01" {
t.Errorf("Date = %q, want %q", info.Date, "2026-04-01")
}
if len(info.Assets) != 3 {
t.Errorf("len(Assets) = %d, want 3", len(info.Assets))
}
}
func TestFetchReleaseByTag(t *testing.T) {
release := GitHubRelease{
TagName: "v1.0.5",
PublishedAt: "2026-03-25T10:00:00Z",
Assets: []GitHubAsset{
{Name: "dws-darwin-arm64.tar.gz", Size: 3800000, BrowserDownloadURL: "https://example.com/dws-darwin-arm64.tar.gz"},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expected := "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tags/v1.0.5"
if r.URL.Path != expected {
t.Errorf("path = %q, want %q", r.URL.Path, expected)
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(release)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
// Without "v" prefix
info, err := client.FetchReleaseByTag("1.0.5")
if err != nil {
t.Fatalf("FetchReleaseByTag() error = %v", err)
}
if info.Version != "1.0.5" {
t.Errorf("Version = %q, want %q", info.Version, "1.0.5")
}
}
func TestFetchAllReleases(t *testing.T) {
releases := []GitHubRelease{
{TagName: "v1.0.6", PublishedAt: "2026-04-01T09:00:00Z", Draft: false, Prerelease: false},
{TagName: "v1.0.6-beta", PublishedAt: "2026-03-28T09:00:00Z", Draft: false, Prerelease: true},
{TagName: "v1.0.5", PublishedAt: "2026-03-25T09:00:00Z", Draft: false, Prerelease: false},
{TagName: "v1.0.4-draft", PublishedAt: "2026-03-20T09:00:00Z", Draft: true, Prerelease: false},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(releases)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
versions, err := client.FetchAllReleases()
if err != nil {
t.Fatalf("FetchAllReleases() error = %v", err)
}
// Draft should be filtered out
if len(versions) != 3 {
t.Fatalf("len(versions) = %d, want 3", len(versions))
}
if versions[0].Version != "1.0.6" {
t.Errorf("versions[0].Version = %q, want %q", versions[0].Version, "1.0.6")
}
if versions[1].Prerelease != true {
t.Errorf("versions[1].Prerelease = false, want true")
}
}
func TestFindBinaryAsset(t *testing.T) {
assets := []GitHubAsset{
{Name: "checksums.txt", Size: 534},
{Name: "dws-darwin-amd64.tar.gz", Size: 4276643},
{Name: "dws-darwin-arm64.tar.gz", Size: 3942308},
{Name: "dws-linux-amd64.tar.gz", Size: 4394696},
{Name: "dws-windows-amd64.zip", Size: 4500000},
{Name: "dws-skills.zip", Size: 50000},
}
tests := []struct {
goos, goarch string
wantName string
wantErr bool
}{
{"darwin", "arm64", "dws-darwin-arm64.tar.gz", false},
{"darwin", "amd64", "dws-darwin-amd64.tar.gz", false},
{"linux", "amd64", "dws-linux-amd64.tar.gz", false},
{"windows", "amd64", "dws-windows-amd64.zip", false},
{"freebsd", "amd64", "", true},
}
for _, tt := range tests {
asset, err := FindBinaryAssetFor(assets, tt.goos, tt.goarch)
if tt.wantErr {
if err == nil {
t.Errorf("FindBinaryAssetFor(%s, %s) expected error", tt.goos, tt.goarch)
}
continue
}
if err != nil {
t.Errorf("FindBinaryAssetFor(%s, %s) error = %v", tt.goos, tt.goarch, err)
continue
}
if asset.Name != tt.wantName {
t.Errorf("FindBinaryAssetFor(%s, %s).Name = %q, want %q", tt.goos, tt.goarch, asset.Name, tt.wantName)
}
}
}
func TestFindSkillsAsset(t *testing.T) {
assets := []GitHubAsset{
{Name: "dws-darwin-arm64.tar.gz"},
{Name: "dws-skills.zip", Size: 50000},
{Name: "checksums.txt"},
}
skill := FindSkillsAsset(assets)
if skill == nil {
t.Fatal("FindSkillsAsset() returned nil")
}
if skill.Name != "dws-skills.zip" {
t.Errorf("Name = %q, want %q", skill.Name, "dws-skills.zip")
}
// No skills asset
noSkills := []GitHubAsset{{Name: "dws-darwin-arm64.tar.gz"}}
if got := FindSkillsAsset(noSkills); got != nil {
t.Errorf("FindSkillsAsset() = %v, want nil", got)
}
}
func TestFindChecksumsAsset(t *testing.T) {
assets := []GitHubAsset{
{Name: "dws-darwin-arm64.tar.gz"},
{Name: "checksums.txt", Size: 534},
}
checksums := FindChecksumsAsset(assets)
if checksums == nil {
t.Fatal("FindChecksumsAsset() returned nil")
}
if checksums.Name != "checksums.txt" {
t.Errorf("Name = %q, want %q", checksums.Name, "checksums.txt")
}
}
func TestExtractDigestSHA256(t *testing.T) {
tests := []struct {
digest string
want string
}{
{"sha256:abcdef1234567890", "abcdef1234567890"},
{"sha256:", ""},
{"md5:abcdef", ""},
{"", ""},
}
for _, tt := range tests {
got := ExtractDigestSHA256(tt.digest)
if got != tt.want {
t.Errorf("ExtractDigestSHA256(%q) = %q, want %q", tt.digest, got, tt.want)
}
}
}
func TestRateLimitError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "0")
w.WriteHeader(http.StatusForbidden)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
_, err := client.FetchLatestRelease()
if err == nil {
t.Fatal("expected rate limit error")
}
if got := err.Error(); !contains(got, "频率超限") {
t.Errorf("error = %q, want to contain rate limit message", got)
}
}
func TestFetchLatestRelease_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
_, err := client.FetchLatestRelease()
if err == nil {
t.Fatal("expected error for 404")
}
if !contains(err.Error(), "404") {
t.Errorf("error = %q, want to contain '404'", err.Error())
}
}
func TestFetchReleaseByTag_InvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{invalid json"))
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
_, err := client.FetchReleaseByTag("v1.0.0")
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestFetchLatestRelease_FieldMapping(t *testing.T) {
release := GitHubRelease{
TagName: "v2.1.0",
Body: "## Changelog\n* abc1234 - add new feature\n* def5678 Merge branch 'main'",
Prerelease: true,
PublishedAt: "2026-06-15T14:30:00Z",
HTMLURL: "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tag/v2.1.0",
Assets: []GitHubAsset{},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(release)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
info, err := client.FetchLatestRelease()
if err != nil {
t.Fatalf("error = %v", err)
}
if info.Version != "2.1.0" {
t.Errorf("Version = %q, want %q", info.Version, "2.1.0")
}
if info.Date != "2026-06-15" {
t.Errorf("Date = %q, want %q", info.Date, "2026-06-15")
}
if !info.Prerelease {
t.Error("Prerelease = false, want true")
}
if info.HTMLURL != release.HTMLURL {
t.Errorf("HTMLURL = %q, want %q", info.HTMLURL, release.HTMLURL)
}
if info.Changelog == "" {
t.Error("Changelog should not be empty")
}
}
func TestFetchAllReleases_EmptyList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]GitHubRelease{})
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
versions, err := client.FetchAllReleases()
if err != nil {
t.Fatalf("error = %v", err)
}
if len(versions) != 0 {
t.Errorf("len = %d, want 0", len(versions))
}
}
func TestFetchAllReleases_AllDrafts(t *testing.T) {
releases := []GitHubRelease{
{TagName: "v1.0.0", Draft: true},
{TagName: "v0.9.0", Draft: true},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(releases)
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
versions, err := client.FetchAllReleases()
if err != nil {
t.Fatalf("error = %v", err)
}
if len(versions) != 0 {
t.Errorf("all-drafts: len = %d, want 0", len(versions))
}
}
func TestFetchReleaseByTag_WithVPrefix(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expected := "/repos/DingTalk-Real-AI/dingtalk-workspace-cli/releases/tags/v1.0.5"
if r.URL.Path != expected {
t.Errorf("path = %q, want %q", r.URL.Path, expected)
}
json.NewEncoder(w).Encode(GitHubRelease{TagName: "v1.0.5", PublishedAt: "2026-01-01T00:00:00Z"})
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
info, err := client.FetchReleaseByTag("v1.0.5") // already has "v"
if err != nil {
t.Fatalf("error = %v", err)
}
if info.Version != "1.0.5" {
t.Errorf("Version = %q, want %q", info.Version, "1.0.5")
}
}
func TestFindChecksumsAsset_NotFound(t *testing.T) {
assets := []GitHubAsset{{Name: "dws-darwin-arm64.tar.gz"}}
if got := FindChecksumsAsset(assets); got != nil {
t.Errorf("FindChecksumsAsset() = %v, want nil", got)
}
}
func TestFindBinaryAssetFor_EmptyAssets(t *testing.T) {
_, err := FindBinaryAssetFor(nil, "darwin", "arm64")
if err == nil {
t.Error("expected error for empty assets")
}
}
func TestStripV(t *testing.T) {
tests := []struct{ in, want string }{
{"v1.0.0", "1.0.0"},
{"1.0.0", "1.0.0"},
{"v", ""},
{"", ""},
}
for _, tt := range tests {
got := stripV(tt.in)
if got != tt.want {
t.Errorf("stripV(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestFormatDate(t *testing.T) {
tests := []struct{ in, want string }{
{"2026-04-01T09:29:05Z", "2026-04-01"},
{"invalid-date", "invalid-date"},
{"", ""},
}
for _, tt := range tests {
got := formatDate(tt.in)
if got != tt.want {
t.Errorf("formatDate(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTruncateBody(t *testing.T) {
tests := []struct {
body string
maxLen int
want string
}{
{"short", 100, "short"},
{"line1\nline2", 100, "line1"},
{"a very long string here", 10, "a very ..."},
{"", 10, ""},
}
for _, tt := range tests {
got := truncateBody(tt.body, tt.maxLen)
if got != tt.want {
t.Errorf("truncateBody(%q, %d) = %q, want %q", tt.body, tt.maxLen, got, tt.want)
}
}
}
func TestGithubToken(t *testing.T) {
// Clean env first
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("GH_TOKEN", "")
if got := githubToken(); got != "" {
t.Errorf("expected empty token, got %q", got)
}
t.Setenv("GITHUB_TOKEN", "gh-tok-123")
if got := githubToken(); got != "gh-tok-123" {
t.Errorf("expected gh-tok-123, got %q", got)
}
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("GH_TOKEN", "gh-alt-456")
if got := githubToken(); got != "gh-alt-456" {
t.Errorf("expected gh-alt-456, got %q", got)
}
}
func TestGetJSON_SetsHeaders(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "test-token-abc")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ua := r.Header.Get("User-Agent"); ua != userAgent {
t.Errorf("User-Agent = %q, want %q", ua, userAgent)
}
if accept := r.Header.Get("Accept"); accept != "application/vnd.github+json" {
t.Errorf("Accept = %q, want application/vnd.github+json", accept)
}
if auth := r.Header.Get("Authorization"); auth != "token test-token-abc" {
t.Errorf("Authorization = %q, want %q", auth, "token test-token-abc")
}
json.NewEncoder(w).Encode(GitHubRelease{TagName: "v1.0.0", PublishedAt: "2026-01-01T00:00:00Z"})
}))
defer server.Close()
client := NewClientWithBaseURL(server.URL)
_, err := client.FetchLatestRelease()
if err != nil {
t.Fatalf("error = %v", err)
}
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
-249
View File
@@ -1,249 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
// Permission constants following Unix best practices.
const (
dirPermSecure os.FileMode = 0o700
dirPermShared os.FileMode = 0o755
filePermBinary os.FileMode = 0o755
filePermConfig os.FileMode = 0o644
)
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
// Kept in sync with build/npm/install.js AGENT_DIRS.
// The first entry (.agents/skills) is always updated; subsequent entries are
// only updated when their parent directory already exists.
var knownSkillDirs = []string{
".agents/skills",
".claude/skills",
".cursor/skills",
".gemini/skills",
".codex/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
".cline/skills",
".amp/skills",
".kiro/skills",
".trae/skills",
}
// skillDirBlacklist contains parent directories whose skills are managed by
// external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade.
var skillDirBlacklist = []string{
".real",
}
// SkillDirStatus describes the installation outcome for a single skill directory.
type SkillDirStatus int
const (
SkillDirOK SkillDirStatus = iota // successfully installed
SkillDirSkipped // agent not detected, directory skipped
SkillDirBlacklisted // blacklisted, never touched
SkillDirFailed // installation attempted but failed
)
// SkillDirResult holds the per-directory install result.
type SkillDirResult struct {
Dir string // destination directory (e.g. ~/.claude/skills/dws)
Status SkillDirStatus // outcome
Err error // non-nil when Status == SkillDirFailed
}
// SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call.
type SkillUpgradeResult struct {
Results []SkillDirResult
}
// Succeeded returns directories that were successfully updated.
func (r *SkillUpgradeResult) Succeeded() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirOK {
out = append(out, d)
}
}
return out
}
// Failed returns directories where installation was attempted but failed.
func (r *SkillUpgradeResult) Failed() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirFailed {
out = append(out, d)
}
}
return out
}
// UpgradeSkillLocations installs skills from extractedDir into all locations
// where they are currently installed or expected.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - ~/.agents/skills/dws/ is ALWAYS updated (primary install location)
// - Other agent dirs (claude, cursor, ...) are updated only when the parent
// directory exists (e.g. ~/.claude/ exists => user has Claude)
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
result := &SkillUpgradeResult{}
for i, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
if i > 0 {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
}
os.RemoveAll(destDir)
if err := copyDir(extractedDir, destDir); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 {
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
os.MkdirAll(filepath.Dir(dest), dirPermShared)
if err := copyDir(extractedDir, dest); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == dest {
result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK})
}
}
return result, nil
}
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
// It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md).
func LocateSkillMD(extractDir string) string {
// Check nested: {extractDir}/dws/SKILL.md
nested := filepath.Join(extractDir, "dws", "SKILL.md")
if _, err := os.Stat(nested); err == nil {
return filepath.Join(extractDir, "dws")
}
// Check flat: {extractDir}/SKILL.md
flat := filepath.Join(extractDir, "SKILL.md")
if _, err := os.Stat(flat); err == nil {
return extractDir
}
return ""
}
// EnsureUpgradeDirectories creates the directories needed for upgrade operations.
func EnsureUpgradeDirectories() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
dirs := []struct {
path string
perm os.FileMode
}{
{filepath.Join(homeDir, ".dws"), dirPermSecure},
{filepath.Join(homeDir, ".dws", "data"), dirPermSecure},
{filepath.Join(homeDir, ".dws", "data", "backups"), dirPermSecure},
{filepath.Join(homeDir, ".dws", "cache"), dirPermSecure},
{filepath.Join(homeDir, ".dws", "cache", "downloads"), dirPermSecure},
}
for _, d := range dirs {
if err := ensureDir(d.path, d.perm); err != nil {
return err
}
}
return nil
}
// DownloadCacheDir returns the path for temporary downloads during upgrade.
func DownloadCacheDir() string {
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, ".dws", "cache", "downloads")
}
// CurrentBinaryPath returns the resolved path of the currently running binary.
func CurrentBinaryPath() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.EvalSymlinks(exe)
}
// BinaryName returns the platform-specific binary name.
func BinaryName() string {
if runtime.GOOS == "windows" {
return "dws.exe"
}
return "dws"
}
func isBlacklisted(agentDir string) bool {
for _, bl := range skillDirBlacklist {
// agentDir is like ".real/skills" — check if it starts with a blacklisted prefix
if len(agentDir) >= len(bl) && agentDir[:len(bl)] == bl {
next := len(bl)
if next == len(agentDir) || agentDir[next] == '/' {
return true
}
}
}
return false
}
func ensureDir(path string, perm os.FileMode) error {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return os.MkdirAll(path, perm)
}
if err != nil {
return err
}
if runtime.GOOS != "windows" && info.Mode().Perm() != perm {
if info.Mode().Perm()&^perm != 0 {
return os.Chmod(path, perm)
}
}
return nil
}
-252
View File
@@ -1,252 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"os"
"path/filepath"
"testing"
)
func TestIsBlacklisted(t *testing.T) {
tests := []struct {
dir string
want bool
}{
{".real/skills", true},
{".real", true},
{".agents/skills", false},
{".claude/skills", false},
{".cursor/skills", false},
{".realtime/skills", false},
}
for _, tt := range tests {
got := isBlacklisted(tt.dir)
if got != tt.want {
t.Errorf("isBlacklisted(%q) = %v, want %v", tt.dir, got, tt.want)
}
}
}
func TestLocateSkillMD(t *testing.T) {
// Flat layout
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# skill"), 0644)
result := LocateSkillMD(dir)
if result != dir {
t.Errorf("flat layout: LocateSkillMD() = %q, want %q", result, dir)
}
// Nested layout
dir2 := t.TempDir()
os.MkdirAll(filepath.Join(dir2, "dws"), 0755)
os.WriteFile(filepath.Join(dir2, "dws", "SKILL.md"), []byte("# skill"), 0644)
result2 := LocateSkillMD(dir2)
want2 := filepath.Join(dir2, "dws")
if result2 != want2 {
t.Errorf("nested layout: LocateSkillMD() = %q, want %q", result2, want2)
}
// No SKILL.md
dir3 := t.TempDir()
result3 := LocateSkillMD(dir3)
if result3 != "" {
t.Errorf("empty dir: LocateSkillMD() = %q, want empty", result3)
}
}
func TestUpgradeSkillLocations(t *testing.T) {
skillSrc := t.TempDir()
os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# test skill"), 0644)
os.MkdirAll(filepath.Join(skillSrc, "references"), 0755)
os.WriteFile(filepath.Join(skillSrc, "references", "guide.md"), []byte("# guide"), 0644)
result, err := UpgradeSkillLocations(skillSrc)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
succeeded := result.Succeeded()
if len(succeeded) == 0 {
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
}
homeDir, _ := os.UserHomeDir()
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
found := false
for _, d := range succeeded {
if d.Dir == primaryDest {
found = true
break
}
}
if !found {
dirs := make([]string, len(succeeded))
for i, d := range succeeded {
dirs[i] = d.Dir
}
t.Errorf("primary location %s not in succeeded list: %v", primaryDest, dirs)
}
skillMDPath := filepath.Join(primaryDest, "SKILL.md")
if _, err := os.Stat(skillMDPath); os.IsNotExist(err) {
t.Errorf("SKILL.md not found at %s", skillMDPath)
}
guidePath := filepath.Join(primaryDest, "references", "guide.md")
if _, err := os.Stat(guidePath); os.IsNotExist(err) {
t.Errorf("references/guide.md not found at %s", guidePath)
}
// Verify that failed list is empty for a normal install
if len(result.Failed()) != 0 {
t.Errorf("expected 0 failures, got %d", len(result.Failed()))
}
// Verify total results contain at least one entry for each known (non-blacklisted) dir
if len(result.Results) == 0 {
t.Error("expected non-empty Results")
}
os.RemoveAll(primaryDest)
}
func TestBlacklistPreventsRealDir(t *testing.T) {
for _, dir := range knownSkillDirs {
if isBlacklisted(dir) {
t.Errorf("knownSkillDirs contains blacklisted entry: %q", dir)
}
}
}
func TestSkillUpgradeResult_SucceededAndFailed(t *testing.T) {
result := &SkillUpgradeResult{
Results: []SkillDirResult{
{Dir: "/a", Status: SkillDirOK},
{Dir: "/b", Status: SkillDirFailed, Err: os.ErrPermission},
{Dir: "/c", Status: SkillDirSkipped},
{Dir: "/d", Status: SkillDirBlacklisted},
{Dir: "/e", Status: SkillDirOK},
},
}
succeeded := result.Succeeded()
if len(succeeded) != 2 {
t.Errorf("Succeeded() len = %d, want 2", len(succeeded))
}
if succeeded[0].Dir != "/a" || succeeded[1].Dir != "/e" {
t.Errorf("Succeeded() dirs = %v, %v", succeeded[0].Dir, succeeded[1].Dir)
}
failed := result.Failed()
if len(failed) != 1 {
t.Errorf("Failed() len = %d, want 1", len(failed))
}
if failed[0].Dir != "/b" {
t.Errorf("Failed()[0].Dir = %q, want /b", failed[0].Dir)
}
if failed[0].Err != os.ErrPermission {
t.Errorf("Failed()[0].Err = %v, want ErrPermission", failed[0].Err)
}
}
func TestSkillUpgradeResult_EmptyResults(t *testing.T) {
result := &SkillUpgradeResult{}
if len(result.Succeeded()) != 0 {
t.Error("empty Succeeded should be nil/empty")
}
if len(result.Failed()) != 0 {
t.Error("empty Failed should be nil/empty")
}
}
func TestSkillDirStatusConstants(t *testing.T) {
if SkillDirOK != 0 {
t.Errorf("SkillDirOK = %d, want 0", SkillDirOK)
}
if SkillDirSkipped != 1 {
t.Errorf("SkillDirSkipped = %d, want 1", SkillDirSkipped)
}
if SkillDirBlacklisted != 2 {
t.Errorf("SkillDirBlacklisted = %d, want 2", SkillDirBlacklisted)
}
if SkillDirFailed != 3 {
t.Errorf("SkillDirFailed = %d, want 3", SkillDirFailed)
}
}
func TestEnsureUpgradeDirectories(t *testing.T) {
// This test verifies the structure is correct without actually modifying $HOME
homeDir, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot get home dir")
}
err = EnsureUpgradeDirectories()
if err != nil {
t.Fatalf("EnsureUpgradeDirectories() error = %v", err)
}
expectedDirs := []string{
filepath.Join(homeDir, ".dws"),
filepath.Join(homeDir, ".dws", "data"),
filepath.Join(homeDir, ".dws", "data", "backups"),
filepath.Join(homeDir, ".dws", "cache"),
filepath.Join(homeDir, ".dws", "cache", "downloads"),
}
for _, d := range expectedDirs {
info, err := os.Stat(d)
if err != nil {
t.Errorf("directory %s should exist: %v", d, err)
continue
}
if !info.IsDir() {
t.Errorf("%s should be a directory", d)
}
}
}
func TestDownloadCacheDir(t *testing.T) {
dir := DownloadCacheDir()
if dir == "" {
t.Error("DownloadCacheDir() returned empty")
}
if !filepath.IsAbs(dir) {
t.Errorf("DownloadCacheDir() = %q, want absolute path", dir)
}
}
func TestBinaryName(t *testing.T) {
name := BinaryName()
if name != "dws" && name != "dws.exe" {
t.Errorf("BinaryName() = %q, want dws or dws.exe", name)
}
}
func TestLocateSkillMD_NestedBeatsFlat(t *testing.T) {
dir := t.TempDir()
// Create both flat and nested layouts; nested should take priority
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("flat"), 0644)
os.MkdirAll(filepath.Join(dir, "dws"), 0755)
os.WriteFile(filepath.Join(dir, "dws", "SKILL.md"), []byte("nested"), 0644)
result := LocateSkillMD(dir)
want := filepath.Join(dir, "dws")
if result != want {
t.Errorf("LocateSkillMD() = %q, want nested %q", result, want)
}
}
func TestKnownSkillDirsNotEmpty(t *testing.T) {
if len(knownSkillDirs) == 0 {
t.Error("knownSkillDirs is empty")
}
// First entry should be the primary location
if knownSkillDirs[0] != ".agents/skills" {
t.Errorf("first knownSkillDir = %q, want .agents/skills", knownSkillDirs[0])
}
}
-203
View File
@@ -1,203 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
// ReplaceSelf atomically replaces the currently running binary with newBinaryPath.
//
// On Unix (macOS / Linux):
// 1. Try atomic os.Rename (same filesystem, instant swap)
// 2. Fallback to copy if cross-device
//
// On Windows the running .exe is locked by the OS, so direct overwrite fails.
// Strategy: rename running exe → .old, then rename/copy new binary in, then
// clean up .old (best-effort, may be cleaned on next run).
func ReplaceSelf(newBinaryPath string) error {
currentExe, err := os.Executable()
if err != nil {
return fmt.Errorf("无法获取当前二进制路径: %w", err)
}
currentExe, err = filepath.EvalSymlinks(currentExe)
if err != nil {
return fmt.Errorf("无法解析符号链接: %w", err)
}
if err := os.Chmod(newBinaryPath, filePermBinary); err != nil {
return fmt.Errorf("设置权限失败: %w", err)
}
if err := replaceExeFile(newBinaryPath, currentExe); err != nil {
return err
}
syncParentDir(currentExe)
return nil
}
// replaceExeFile replaces dst with src, handling Windows file-lock semantics.
func replaceExeFile(src, dst string) error {
// Fast path: atomic rename (works on Unix same-filesystem, or Windows when dst is unlocked)
if err := os.Rename(src, dst); err == nil {
return nil
}
if runtime.GOOS == "windows" {
return windowsReplace(src, dst)
}
// Unix cross-device fallback
return copyFile(src, dst, filePermBinary)
}
// windowsReplace handles the Windows-specific case where the running exe is locked.
// Windows allows renaming a running executable but not overwriting it.
func windowsReplace(src, dst string) error {
oldPath := dst + ".old"
// Clean up leftover .old from a previous upgrade
os.Remove(oldPath)
// Move the running (locked) binary out of the way
if err := os.Rename(dst, oldPath); err != nil {
return fmt.Errorf("无法移动正在运行的二进制文件: %w", err)
}
// Place the new binary at the target path
if err := os.Rename(src, dst); err != nil {
// Cross-device fallback
if cpErr := copyFile(src, dst, filePermBinary); cpErr != nil {
// Attempt to restore the original
os.Rename(oldPath, dst)
return fmt.Errorf("替换失败: %w", cpErr)
}
}
// Best-effort cleanup; the .old file may still be locked and will be
// removed on the next upgrade or reboot.
os.Remove(oldPath)
return nil
}
// CleanupStaleFiles removes leftover .old and .rollback-tmp files from
// previous upgrades (relevant on Windows where locked files cannot be
// deleted immediately).
func CleanupStaleFiles() {
exe, err := CurrentBinaryPath()
if err != nil {
return
}
os.Remove(exe + ".old")
os.Remove(exe + ".rollback-tmp")
}
// ExtractZip unzips zipPath contents into targetDir.
// Contains zip-slip protection against path traversal attacks.
func ExtractZip(zipPath, targetDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("打开 zip 失败: %w", err)
}
defer r.Close()
targetDir = filepath.Clean(targetDir)
for _, f := range r.File {
destPath := filepath.Join(targetDir, f.Name)
rel, err := filepath.Rel(targetDir, destPath)
if err != nil || strings.HasPrefix(rel, "..") {
continue // zip-slip guard
}
if f.FileInfo().IsDir() {
os.MkdirAll(destPath, dirPermShared)
continue
}
if err := extractZipEntry(f, destPath); err != nil {
return err
}
}
return nil
}
// FindBinaryInDir recursively finds the dws binary in an extracted directory.
func FindBinaryInDir(dir string) string {
candidates := []string{
filepath.Join(dir, "dws"),
filepath.Join(dir, "dws.exe"),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
var found string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
name := info.Name()
if name == "dws" || name == "dws.exe" {
found = path
return filepath.SkipAll
}
return nil
})
return found
}
func extractZipEntry(f *zip.File, destPath string) error {
os.MkdirAll(filepath.Dir(destPath), dirPermShared)
rc, err := f.Open()
if err != nil {
return fmt.Errorf("读取 zip 条目失败: %w", err)
}
defer rc.Close()
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermConfig)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}
func copyFile(src, dst string, perm os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func syncParentDir(path string) {
dir := filepath.Dir(path)
f, err := os.Open(dir)
if err != nil {
return
}
f.Sync()
f.Close()
}
-264
View File
@@ -1,264 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func TestExtractZip_BasicFiles(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "test.zip")
targetDir := filepath.Join(dir, "extracted")
createTestZip(t, zipPath, map[string]string{
"README.md": "# Hello",
"dws/SKILL.md": "# Skill",
"dws/data.txt": "some data",
})
if err := ExtractZip(zipPath, targetDir); err != nil {
t.Fatalf("ExtractZip() error = %v", err)
}
assertFileContent(t, filepath.Join(targetDir, "README.md"), "# Hello")
assertFileContent(t, filepath.Join(targetDir, "dws", "SKILL.md"), "# Skill")
assertFileContent(t, filepath.Join(targetDir, "dws", "data.txt"), "some data")
}
func TestExtractZip_ZipSlipProtection(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "evil.zip")
targetDir := filepath.Join(dir, "extracted")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
// Attempt path traversal
ew, _ := w.Create("../../etc/passwd")
ew.Write([]byte("root:x:0:0"))
// Normal file
nw, _ := w.Create("normal.txt")
nw.Write([]byte("ok"))
w.Close()
f.Close()
if err := ExtractZip(zipPath, targetDir); err != nil {
t.Fatalf("ExtractZip() error = %v", err)
}
// Path-traversal entry should be skipped
evilPath := filepath.Join(dir, "..", "etc", "passwd")
if _, err := os.Stat(evilPath); err == nil {
t.Error("zip-slip protection failed: traversal file was extracted")
}
// Normal file should exist
assertFileContent(t, filepath.Join(targetDir, "normal.txt"), "ok")
}
func TestExtractZip_InvalidZip(t *testing.T) {
dir := t.TempDir()
badPath := filepath.Join(dir, "bad.zip")
os.WriteFile(badPath, []byte("not a zip"), 0644)
err := ExtractZip(badPath, filepath.Join(dir, "out"))
if err == nil {
t.Error("ExtractZip() expected error for invalid zip")
}
}
func TestExtractZip_EmptyZip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "empty.zip")
createTestZip(t, zipPath, map[string]string{})
targetDir := filepath.Join(dir, "extracted")
if err := ExtractZip(zipPath, targetDir); err != nil {
t.Fatalf("ExtractZip() error = %v for empty zip", err)
}
}
func TestFindBinaryInDir_TopLevel(t *testing.T) {
dir := t.TempDir()
dwsPath := filepath.Join(dir, "dws")
os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
got := FindBinaryInDir(dir)
if got != dwsPath {
t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
}
}
func TestFindBinaryInDir_Nested(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "subdir")
os.MkdirAll(nested, 0755)
dwsPath := filepath.Join(nested, "dws")
os.WriteFile(dwsPath, []byte("#!/bin/sh"), 0755)
got := FindBinaryInDir(dir)
if got != dwsPath {
t.Errorf("FindBinaryInDir() = %q, want %q", got, dwsPath)
}
}
func TestFindBinaryInDir_Windows(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "dws.exe")
os.WriteFile(exePath, []byte("MZ"), 0755)
got := FindBinaryInDir(dir)
if got != exePath {
t.Errorf("FindBinaryInDir() = %q, want %q", got, exePath)
}
}
func TestFindBinaryInDir_NotFound(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "other.bin"), []byte("x"), 0755)
got := FindBinaryInDir(dir)
if got != "" {
t.Errorf("FindBinaryInDir() = %q, want empty", got)
}
}
func TestCopyFile(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
os.WriteFile(src, []byte("copy me"), 0644)
if err := copyFile(src, dst, 0755); err != nil {
t.Fatalf("copyFile() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "copy me" {
t.Errorf("dst content = %q, want %q", string(got), "copy me")
}
info, _ := os.Stat(dst)
if info.Mode().Perm()&0755 != 0755 {
t.Errorf("dst perm = %o, want 0755", info.Mode().Perm())
}
}
func TestCopyFile_SrcNotExist(t *testing.T) {
dir := t.TempDir()
err := copyFile(filepath.Join(dir, "missing"), filepath.Join(dir, "dst"), 0644)
if err == nil {
t.Error("copyFile() expected error for missing src")
}
}
func TestReplaceExeFile_AtomicRename(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "new-binary")
dst := filepath.Join(dir, "dws")
os.WriteFile(dst, []byte("old"), 0755)
os.WriteFile(src, []byte("new"), 0755)
if err := replaceExeFile(src, dst); err != nil {
t.Fatalf("replaceExeFile() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "new" {
t.Errorf("dst content = %q, want %q", string(got), "new")
}
// src should no longer exist (it was renamed)
if _, err := os.Stat(src); err == nil {
t.Error("src should have been renamed away")
}
}
func TestWindowsReplace_Basic(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "new-binary")
dst := filepath.Join(dir, "dws.exe")
os.WriteFile(dst, []byte("old-exe"), 0755)
os.WriteFile(src, []byte("new-exe"), 0755)
if err := windowsReplace(src, dst); err != nil {
t.Fatalf("windowsReplace() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "new-exe" {
t.Errorf("dst content = %q, want %q", string(got), "new-exe")
}
// .old should have been cleaned up
if _, err := os.Stat(dst + ".old"); err == nil {
t.Error(".old file should have been cleaned up")
}
}
func TestWindowsReplace_CleansUpStaleOld(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "new-binary")
dst := filepath.Join(dir, "dws.exe")
oldPath := dst + ".old"
os.WriteFile(dst, []byte("current"), 0755)
os.WriteFile(src, []byte("newer"), 0755)
os.WriteFile(oldPath, []byte("stale-old"), 0755)
if err := windowsReplace(src, dst); err != nil {
t.Fatalf("windowsReplace() error = %v", err)
}
got, _ := os.ReadFile(dst)
if string(got) != "newer" {
t.Errorf("dst content = %q, want %q", string(got), "newer")
}
}
func TestCleanupStaleFiles(t *testing.T) {
// Just verify it doesn't panic; the actual files to clean depend on os.Executable()
CleanupStaleFiles()
}
// --- helpers ---
func createTestZip(t *testing.T, zipPath string, files map[string]string) {
t.Helper()
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte(content))
}
w.Close()
f.Close()
}
func assertFileContent(t *testing.T, path, want string) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil {
t.Errorf("cannot read %s: %v", path, err)
return
}
if string(got) != want {
t.Errorf("content of %s = %q, want %q", path, string(got), want)
}
}
-299
View File
@@ -1,299 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"time"
)
const defaultMaxBackups = 5
// BackupInfo contains information about a single backup.
type BackupInfo struct {
Path string `json:"path"`
BinaryPath string `json:"binaryPath"`
Version string `json:"version"`
CreatedAt time.Time `json:"createdAt"`
Size int64 `json:"size"`
}
// RollbackManager manages backup and rollback operations.
type RollbackManager struct {
backupDir string
maxBackups int
}
// NewRollbackManager creates a rollback manager using the standard backup directory.
func NewRollbackManager() *RollbackManager {
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "."
}
return &RollbackManager{
backupDir: filepath.Join(homeDir, ".dws", "data", "backups"),
maxBackups: defaultMaxBackups,
}
}
// NewRollbackManagerWithDir creates a rollback manager with a custom directory.
func NewRollbackManagerWithDir(backupDir string) *RollbackManager {
return &RollbackManager{
backupDir: backupDir,
maxBackups: defaultMaxBackups,
}
}
// Backup creates a backup of the currently running binary.
// Returns the backup directory path.
func (r *RollbackManager) Backup(currentVersion string) (string, error) {
currentExe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("无法获取当前二进制路径: %w", err)
}
currentExe, err = filepath.EvalSymlinks(currentExe)
if err != nil {
return "", fmt.Errorf("无法解析符号链接: %w", err)
}
info, err := os.Stat(currentExe)
if err != nil {
return "", fmt.Errorf("无法读取当前二进制信息: %w", err)
}
if err := os.MkdirAll(r.backupDir, dirPermSecure); err != nil {
return "", fmt.Errorf("创建备份目录失败: %w", err)
}
timestamp := time.Now().Format("20060102-150405")
backupSetName := fmt.Sprintf("v%s-%s", currentVersion, timestamp)
backupSetPath := filepath.Join(r.backupDir, backupSetName)
if err := os.MkdirAll(backupSetPath, dirPermSecure); err != nil {
return "", fmt.Errorf("创建备份集目录失败: %w", err)
}
binaryBackupDir := filepath.Join(backupSetPath, "binary")
if err := os.MkdirAll(binaryBackupDir, dirPermSecure); err != nil {
return "", fmt.Errorf("创建二进制备份目录失败: %w", err)
}
binaryBackupPath := filepath.Join(binaryBackupDir, filepath.Base(currentExe))
if err := copyFile(currentExe, binaryBackupPath, info.Mode()); err != nil {
return "", fmt.Errorf("备份二进制失败: %w", err)
}
backupInfo := BackupInfo{
Path: backupSetPath,
BinaryPath: binaryBackupPath,
Version: currentVersion,
CreatedAt: time.Now(),
Size: info.Size(),
}
r.saveBackupInfo(backupInfo)
return backupSetPath, nil
}
// Rollback restores the most recent backup.
func (r *RollbackManager) Rollback() error {
backups, err := r.ListBackups()
if err != nil {
return err
}
if len(backups) == 0 {
return fmt.Errorf("没有可用的备份")
}
return r.RollbackTo(backups[0])
}
// RollbackTo restores a specific backup.
// Uses replaceExeFile to handle Windows file-lock semantics correctly.
func (r *RollbackManager) RollbackTo(backup BackupInfo) error {
currentExe, err := os.Executable()
if err != nil {
return fmt.Errorf("无法获取当前二进制路径: %w", err)
}
currentExe, err = filepath.EvalSymlinks(currentExe)
if err != nil {
return fmt.Errorf("无法解析符号链接: %w", err)
}
binaryBackupPath := backup.BinaryPath
if binaryBackupPath == "" {
binaryBackupPath = filepath.Join(backup.Path, "binary", BinaryName())
}
if _, err := os.Stat(binaryBackupPath); os.IsNotExist(err) {
return fmt.Errorf("备份文件不存在: %s", binaryBackupPath)
}
// Copy backup to a temp file first so replaceExeFile can use rename
tmpPath := currentExe + ".rollback-tmp"
if err := copyFile(binaryBackupPath, tmpPath, filePermBinary); err != nil {
return fmt.Errorf("准备回滚文件失败: %w", err)
}
if err := replaceExeFile(tmpPath, currentExe); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("恢复二进制失败: %w", err)
}
syncFileData(currentExe)
return nil
}
// ListBackups returns all available backups, newest first.
func (r *RollbackManager) ListBackups() ([]BackupInfo, error) {
entries, err := os.ReadDir(r.backupDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("读取备份目录失败: %w", err)
}
var backups []BackupInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
backupPath := filepath.Join(r.backupDir, entry.Name())
info, err := r.loadBackupInfo(backupPath)
if err != nil {
fi, statErr := entry.Info()
if statErr != nil {
continue
}
info = BackupInfo{
Path: backupPath,
Version: parseVersionFromBackupName(entry.Name()),
CreatedAt: fi.ModTime(),
}
}
backups = append(backups, info)
}
sort.Slice(backups, func(i, j int) bool {
return backups[i].CreatedAt.After(backups[j].CreatedAt)
})
return backups, nil
}
// Cleanup removes old backups, keeping only the most recent N.
func (r *RollbackManager) Cleanup(keep int) error {
backups, err := r.ListBackups()
if err != nil {
return err
}
if keep <= 0 {
keep = r.maxBackups
}
for i := keep; i < len(backups); i++ {
os.RemoveAll(backups[i].Path)
}
return nil
}
func (r *RollbackManager) saveBackupInfo(info BackupInfo) {
infoPath := filepath.Join(info.Path, "info.json")
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return
}
os.WriteFile(infoPath, data, filePermConfig)
}
func (r *RollbackManager) loadBackupInfo(backupSetPath string) (BackupInfo, error) {
infoPath := filepath.Join(backupSetPath, "info.json")
data, err := os.ReadFile(infoPath)
if err != nil {
return BackupInfo{}, err
}
var info BackupInfo
if err := json.Unmarshal(data, &info); err != nil {
return BackupInfo{}, err
}
return info, nil
}
// parseVersionFromBackupName extracts version from "v0.2.7-20260314-100523".
func parseVersionFromBackupName(name string) string {
if len(name) > 1 && name[0] == 'v' {
// Find first '-' after version digits
for i := 1; i < len(name); i++ {
if name[i] == '-' {
// Check if next char is a digit (timestamp), meaning this is the separator
if i+1 < len(name) && name[i+1] >= '0' && name[i+1] <= '9' {
return name[1:i]
}
}
}
return name[1:]
}
return "unknown"
}
func syncFileData(path string) {
f, err := os.Open(path)
if err != nil {
return
}
f.Sync()
f.Close()
}
// copyDir recursively copies a directory from src to dst.
func copyDir(src, dst string) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
info, err := entry.Info()
if err != nil {
continue
}
srcFile, err := os.Open(srcPath)
if err != nil {
return err
}
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
srcFile.Close()
return err
}
_, err = io.Copy(dstFile, srcFile)
srcFile.Close()
dstFile.Close()
if err != nil {
return err
}
}
}
return nil
}
-342
View File
@@ -1,342 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestParseVersionFromBackupName(t *testing.T) {
tests := []struct {
name string
want string
}{
{"v1.0.6-20260401-093000", "1.0.6"},
{"v0.2.7-20260314-100523", "0.2.7"},
{"v1.0.0", "1.0.0"},
{"invalid", "unknown"},
{"v", "unknown"},
}
for _, tt := range tests {
got := parseVersionFromBackupName(tt.name)
if got != tt.want {
t.Errorf("parseVersionFromBackupName(%q) = %q, want %q", tt.name, got, tt.want)
}
}
}
func TestRollbackManagerBackupAndList(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
// Create a fake binary to backup
fakeExe := filepath.Join(dir, "dws")
os.WriteFile(fakeExe, []byte("#!/bin/sh\necho fake"), 0755)
// ListBackups on empty dir
backups, err := rm.ListBackups()
if err != nil {
t.Fatalf("ListBackups() error = %v", err)
}
if len(backups) != 0 {
t.Errorf("ListBackups() on empty = %d, want 0", len(backups))
}
}
func TestRollbackManagerCleanup(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
// Create backup directories with proper timestamps in info.json
names := []string{"v1.0.1-20260101-010000", "v1.0.2-20260102-020000", "v1.0.3-20260103-030000"}
for i, name := range names {
backupDir := filepath.Join(dir, name)
os.MkdirAll(backupDir, 0755)
info := BackupInfo{
Path: backupDir,
Version: "1.0." + string(rune('1'+i)),
CreatedAt: mustParseTime(t, "2026-01-0"+string(rune('1'+i))+"T01:00:00Z"),
}
rm.saveBackupInfo(info)
}
// Keep only 1 (the newest)
if err := rm.Cleanup(1); err != nil {
t.Fatalf("Cleanup(1) error = %v", err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("after Cleanup(1), %d entries remain, want 1", len(entries))
}
}
func TestRollbackManagerCleanup_KeepAll(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
for i := 0; i < 3; i++ {
name := "v1.0." + string(rune('0'+i)) + "-20260101-01000" + string(rune('0'+i))
backupDir := filepath.Join(dir, name)
os.MkdirAll(backupDir, 0755)
rm.saveBackupInfo(BackupInfo{
Path: backupDir,
Version: "1.0." + string(rune('0'+i)),
CreatedAt: mustParseTime(t, "2026-01-0"+string(rune('1'+i))+"T01:00:00Z"),
})
}
if err := rm.Cleanup(10); err != nil {
t.Fatalf("Cleanup(10) error = %v", err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 3 {
t.Errorf("after Cleanup(10), %d entries remain, want 3", len(entries))
}
}
func TestRollbackManagerCleanup_EmptyDir(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
if err := rm.Cleanup(1); err != nil {
t.Fatalf("Cleanup on empty dir error = %v", err)
}
}
func TestListBackups_WithInfoJSON(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
backupDir := filepath.Join(dir, "v1.0.5-20260315-120000")
os.MkdirAll(filepath.Join(backupDir, "binary"), 0755)
os.WriteFile(filepath.Join(backupDir, "binary", "dws"), []byte("#!/bin/sh"), 0755)
info := BackupInfo{
Path: backupDir,
BinaryPath: filepath.Join(backupDir, "binary", "dws"),
Version: "1.0.5",
CreatedAt: mustParseTime(t, "2026-03-15T12:00:00Z"),
Size: 1234,
}
rm.saveBackupInfo(info)
backups, err := rm.ListBackups()
if err != nil {
t.Fatalf("ListBackups() error = %v", err)
}
if len(backups) != 1 {
t.Fatalf("ListBackups() len = %d, want 1", len(backups))
}
if backups[0].Version != "1.0.5" {
t.Errorf("Version = %q, want %q", backups[0].Version, "1.0.5")
}
if backups[0].Size != 1234 {
t.Errorf("Size = %d, want 1234", backups[0].Size)
}
if backups[0].BinaryPath == "" {
t.Error("BinaryPath should be set from info.json")
}
}
func TestListBackups_WithoutInfoJSON(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
// Create a backup dir without info.json => should fallback to name parsing
backupDir := filepath.Join(dir, "v1.0.3-20260101-100000")
os.MkdirAll(backupDir, 0755)
backups, err := rm.ListBackups()
if err != nil {
t.Fatalf("ListBackups() error = %v", err)
}
if len(backups) != 1 {
t.Fatalf("len = %d, want 1", len(backups))
}
if backups[0].Version != "1.0.3" {
t.Errorf("fallback Version = %q, want 1.0.3", backups[0].Version)
}
}
func TestListBackups_SortOrder(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
timestamps := []string{
"2026-01-01T01:00:00Z",
"2026-03-15T12:00:00Z",
"2026-02-10T06:00:00Z",
}
for i, ts := range timestamps {
name := "v1.0." + string(rune('0'+i)) + "-backup"
backupDir := filepath.Join(dir, name)
os.MkdirAll(backupDir, 0755)
rm.saveBackupInfo(BackupInfo{
Path: backupDir,
Version: "1.0." + string(rune('0'+i)),
CreatedAt: mustParseTime(t, ts),
})
}
backups, err := rm.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(backups) != 3 {
t.Fatalf("len = %d", len(backups))
}
// Should be sorted newest first
if backups[0].Version != "1.0.1" {
t.Errorf("newest = %q, want 1.0.1 (March)", backups[0].Version)
}
if backups[2].Version != "1.0.0" {
t.Errorf("oldest = %q, want 1.0.0 (January)", backups[2].Version)
}
}
func TestListBackups_NonexistentDir(t *testing.T) {
rm := NewRollbackManagerWithDir("/tmp/nonexistent-dir-" + time.Now().Format("20060102150405"))
backups, err := rm.ListBackups()
if err != nil {
t.Fatalf("error = %v (should return nil for nonexistent dir)", err)
}
if backups != nil {
t.Errorf("should return nil for nonexistent dir, got %d entries", len(backups))
}
}
func TestListBackups_SkipsRegularFiles(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
// Create a regular file (not a directory) — should be ignored
os.WriteFile(filepath.Join(dir, "not-a-backup.txt"), []byte("ignore"), 0644)
// Create an actual backup directory
backupDir := filepath.Join(dir, "v1.0.0-20260101-000000")
os.MkdirAll(backupDir, 0755)
rm.saveBackupInfo(BackupInfo{
Path: backupDir,
Version: "1.0.0",
CreatedAt: mustParseTime(t, "2026-01-01T00:00:00Z"),
})
backups, err := rm.ListBackups()
if err != nil {
t.Fatal(err)
}
if len(backups) != 1 {
t.Errorf("len = %d, want 1 (file should be ignored)", len(backups))
}
}
func TestSaveAndLoadBackupInfo(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
backupDir := filepath.Join(dir, "v1.0.0-test")
os.MkdirAll(backupDir, 0755)
original := BackupInfo{
Path: backupDir,
BinaryPath: filepath.Join(backupDir, "binary", "dws"),
Version: "1.0.0",
CreatedAt: mustParseTime(t, "2026-06-01T10:00:00Z"),
Size: 5678,
}
rm.saveBackupInfo(original)
loaded, err := rm.loadBackupInfo(backupDir)
if err != nil {
t.Fatalf("loadBackupInfo() error = %v", err)
}
if loaded.Version != original.Version {
t.Errorf("Version = %q, want %q", loaded.Version, original.Version)
}
if loaded.Size != original.Size {
t.Errorf("Size = %d, want %d", loaded.Size, original.Size)
}
if loaded.BinaryPath != original.BinaryPath {
t.Errorf("BinaryPath = %q, want %q", loaded.BinaryPath, original.BinaryPath)
}
}
func TestLoadBackupInfo_InvalidJSON(t *testing.T) {
dir := t.TempDir()
rm := NewRollbackManagerWithDir(dir)
backupDir := filepath.Join(dir, "v1.0.0-bad")
os.MkdirAll(backupDir, 0755)
os.WriteFile(filepath.Join(backupDir, "info.json"), []byte("{bad json"), 0644)
_, err := rm.loadBackupInfo(backupDir)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestNewRollbackManager(t *testing.T) {
rm := NewRollbackManager()
if rm.backupDir == "" {
t.Error("backupDir should not be empty")
}
if rm.maxBackups != defaultMaxBackups {
t.Errorf("maxBackups = %d, want %d", rm.maxBackups, defaultMaxBackups)
}
}
func TestCopyDir(t *testing.T) {
src := t.TempDir()
os.WriteFile(filepath.Join(src, "file1.txt"), []byte("content1"), 0644)
os.MkdirAll(filepath.Join(src, "sub"), 0755)
os.WriteFile(filepath.Join(src, "sub", "file2.txt"), []byte("content2"), 0644)
dst := filepath.Join(t.TempDir(), "copy")
if err := copyDir(src, dst); err != nil {
t.Fatalf("copyDir() error = %v", err)
}
got1, _ := os.ReadFile(filepath.Join(dst, "file1.txt"))
if string(got1) != "content1" {
t.Errorf("file1 = %q, want content1", string(got1))
}
got2, _ := os.ReadFile(filepath.Join(dst, "sub", "file2.txt"))
if string(got2) != "content2" {
t.Errorf("sub/file2 = %q, want content2", string(got2))
}
}
func TestCopyDir_EmptySrc(t *testing.T) {
src := t.TempDir()
dst := filepath.Join(t.TempDir(), "copy")
if err := copyDir(src, dst); err != nil {
t.Fatalf("copyDir() error = %v", err)
}
entries, _ := os.ReadDir(dst)
if len(entries) != 0 {
t.Errorf("dst should be empty, got %d entries", len(entries))
}
}
func TestCopyDir_SrcNotExist(t *testing.T) {
err := copyDir("/tmp/nonexistent-src-"+time.Now().Format("150405"), t.TempDir())
if err == nil {
t.Error("expected error for nonexistent src")
}
}
func mustParseTime(t *testing.T, s string) time.Time {
t.Helper()
ts, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatal(err)
}
return ts
}
-66
View File
@@ -1,66 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strings"
)
// VerifySHA256 verifies a file against its expected SHA256 hash.
func VerifySHA256(filePath, expectedHash string) error {
actual, err := ComputeSHA256(filePath)
if err != nil {
return err
}
expectedHash = strings.ToLower(strings.TrimSpace(expectedHash))
if actual != expectedHash {
return fmt.Errorf("SHA256 校验失败: 期望 %s..., 实际 %s...", expectedHash[:16], actual[:16])
}
return nil
}
// ComputeSHA256 computes the SHA256 hash of a file.
func ComputeSHA256(filePath string) (string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("读取文件失败: %w", err)
}
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:]), nil
}
// ParseChecksumFile parses a SHA256SUMS-style file.
// Each line: "hash filename" (two spaces or whitespace separated).
// Returns a map of filename -> lowercase hex hash.
func ParseChecksumFile(content string) map[string]string {
result := make(map[string]string)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
hash := strings.ToLower(parts[0])
filename := parts[1]
result[filename] = hash
}
}
return result
}
// VerifyFileFromChecksums verifies a downloaded file against checksums.txt content.
func VerifyFileFromChecksums(filePath, filename, checksumsContent string) error {
checksums := ParseChecksumFile(checksumsContent)
expectedHash, ok := checksums[filename]
if !ok {
return fmt.Errorf("在校验和文件中未找到 %s", filename)
}
return VerifySHA256(filePath, expectedHash)
}
-85
View File
@@ -1,85 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestComputeSHA256(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "testfile")
content := []byte("hello world")
os.WriteFile(path, content, 0644)
hash, err := ComputeSHA256(path)
if err != nil {
t.Fatalf("ComputeSHA256() error = %v", err)
}
expected := sha256.Sum256(content)
want := hex.EncodeToString(expected[:])
if hash != want {
t.Errorf("ComputeSHA256() = %q, want %q", hash, want)
}
}
func TestVerifySHA256(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "testfile")
content := []byte("hello world")
os.WriteFile(path, content, 0644)
expected := sha256.Sum256(content)
hash := hex.EncodeToString(expected[:])
if err := VerifySHA256(path, hash); err != nil {
t.Errorf("VerifySHA256() with correct hash: error = %v", err)
}
if err := VerifySHA256(path, "0000000000000000000000000000000000000000000000000000000000000000"); err == nil {
t.Error("VerifySHA256() with wrong hash: expected error")
}
}
func TestParseChecksumFile(t *testing.T) {
content := `abc123def456 dws-darwin-arm64.tar.gz
fedcba987654 dws-linux-amd64.tar.gz
112233445566 dws-skills.zip
`
result := ParseChecksumFile(content)
if len(result) != 3 {
t.Fatalf("len(result) = %d, want 3", len(result))
}
if result["dws-darwin-arm64.tar.gz"] != "abc123def456" {
t.Errorf("darwin hash = %q", result["dws-darwin-arm64.tar.gz"])
}
if result["dws-skills.zip"] != "112233445566" {
t.Errorf("skills hash = %q", result["dws-skills.zip"])
}
}
func TestVerifyFileFromChecksums(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "myfile.bin")
content := []byte("test content")
os.WriteFile(path, content, 0644)
expected := sha256.Sum256(content)
hash := hex.EncodeToString(expected[:])
checksums := hash + " myfile.bin\nother otherfile.bin\n"
if err := VerifyFileFromChecksums(path, "myfile.bin", checksums); err != nil {
t.Errorf("VerifyFileFromChecksums() with correct hash: error = %v", err)
}
if err := VerifyFileFromChecksums(path, "missing.bin", checksums); err == nil {
t.Error("VerifyFileFromChecksums() with missing file: expected error")
}
}
-47
View File
@@ -1,47 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"strconv"
"strings"
)
// CompareVersions compares two semver strings (e.g. "1.0.5", "v1.0.6-beta").
// Returns -1 if a < b, 0 if equal, 1 if a > b.
// Only the numeric major.minor.patch segments are compared; prerelease suffixes
// after "-" are stripped before comparison.
func CompareVersions(a, b string) int {
pa := parseVersionParts(a)
pb := parseVersionParts(b)
for i := 0; i < 3; i++ {
if pa[i] < pb[i] {
return -1
}
if pa[i] > pb[i] {
return 1
}
}
return 0
}
// NeedsUpgrade returns true when remoteVersion is newer than currentVersion.
func NeedsUpgrade(currentVersion, remoteVersion string) bool {
return CompareVersions(currentVersion, remoteVersion) < 0
}
func parseVersionParts(v string) [3]int {
v = strings.TrimPrefix(v, "v")
if idx := strings.IndexByte(v, '-'); idx >= 0 {
v = v[:idx]
}
parts := strings.SplitN(v, ".", 3)
var result [3]int
for i, p := range parts {
if i < 3 {
result[i], _ = strconv.Atoi(p)
}
}
return result
}
-51
View File
@@ -1,51 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import "testing"
func TestCompareVersions(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"1.0.0", "1.0.0", 0},
{"1.0.0", "1.0.1", -1},
{"1.0.1", "1.0.0", 1},
{"0.2.7", "0.3.0", -1},
{"1.0.0", "0.9.9", 1},
{"v1.0.6", "1.0.6", 0},
{"v1.0.5", "v1.0.6", -1},
{"1.0.6-beta", "1.0.6", 0},
{"1.0.5-rc1", "1.0.6-beta", -1},
{"2.0.0", "1.99.99", 1},
{"0.0.1", "0.0.0", 1},
}
for _, tt := range tests {
got := CompareVersions(tt.a, tt.b)
if got != tt.want {
t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}
func TestNeedsUpgrade(t *testing.T) {
tests := []struct {
current, remote string
want bool
}{
{"v1.0.6", "1.0.6", false},
{"v1.0.5", "1.0.6", true},
{"v1.0.7", "1.0.6", false},
{"v1.0.6", "1.0.7", true},
}
for _, tt := range tests {
got := NeedsUpgrade(tt.current, tt.remote)
if got != tt.want {
t.Errorf("NeedsUpgrade(%q, %q) = %v, want %v", tt.current, tt.remote, got, tt.want)
}
}
}
-30
View File
@@ -1,30 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package cli exposes public entry points for the DWS CLI so that external
// overlay modules can embed and launch the CLI after customising edition hooks.
package cli
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
// SetVersion overrides the version, build time and git commit strings
// that are displayed by `dws version` and `dws --version`.
// Typically called by overlay main.go with values injected via ldflags.
func SetVersion(v, buildTime, gitCommit string) {
app.SetVersion(v, buildTime, gitCommit)
}
// Execute runs the root CLI command and returns the process exit code.
func Execute() int {
return app.Execute()
}
-166
View File
@@ -1,166 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmdutil
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// FlagFixResult holds the result of SuggestFlagFix analysis.
type FlagFixResult struct {
Suggestion string
AutoFixFlag string
AutoFixValue string
}
// CommonFlagAliases maps commonly misused flag names to their correct equivalents.
var CommonFlagAliases = map[string]string{
"json": "format json",
"output": "format",
"out": "format",
"o": "format",
"silent": "quiet",
"dry": "dry-run",
"force": "yes",
"f": "yes",
"timeout-seconds": "timeout",
"device-flow": "device",
"deviceflow": "device",
}
// DetectNumericTypeError checks if err is a Cobra/pflag numeric type
// validation error. Returns the flag name and the bad value if detected.
func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) {
msg := err.Error()
if !strings.Contains(msg, "strconv.Parse") {
return "", "", false
}
const argPrefix = "invalid argument \""
argIdx := strings.Index(msg, argPrefix)
if argIdx < 0 {
return "", "", false
}
afterArg := msg[argIdx+len(argPrefix):]
argEnd := strings.Index(afterArg, "\"")
if argEnd < 0 {
return "", "", false
}
badVal := afterArg[:argEnd]
const marker = "\" for \"--"
idx := strings.Index(msg, marker)
if idx < 0 {
return "", "", false
}
rest := msg[idx+len(marker):]
endIdx := strings.Index(rest, "\" flag")
if endIdx < 0 {
return "", "", false
}
return rest[:endIdx], badVal, true
}
// SuggestFlagFix detects flag-value concatenation errors, common flag aliases,
// and Levenshtein-close typos.
func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
msg := flagErr.Error()
const prefix = "unknown flag: --"
idx := strings.Index(msg, prefix)
if idx < 0 {
return FlagFixResult{}
}
body := strings.TrimSpace(msg[idx+len(prefix):])
if alias, ok := CommonFlagAliases[body]; ok {
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s? Run '%s --help' for options", alias, cmd.CommandPath())}
}
var bestFlag, bestValue string
cmd.Flags().VisitAll(func(f *pflag.Flag) {
name := f.Name
if strings.HasPrefix(body, name) && len(body) > len(name) {
if len(name) > len(bestFlag) {
bestFlag = name
bestValue = body[len(name):]
}
}
})
if bestFlag != "" {
canAutoFix := len(bestValue) > 0
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
if canAutoFix {
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
}
return FlagFixResult{Suggestion: suggestion}
}
bestName, bestDist := "", 999
cmd.Flags().VisitAll(func(f *pflag.Flag) {
d := LevenshteinDist(body, f.Name)
if d < bestDist {
bestDist = d
bestName = f.Name
}
})
threshold := LevenshteinThreshold(len(body))
if bestDist > 0 && bestDist <= threshold && bestName != "" {
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)}
}
return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())}
}
// LevenshteinThreshold returns the max edit distance allowed based on string length.
func LevenshteinThreshold(nameLen int) int {
if nameLen <= 3 {
return 1
}
if nameLen <= 8 {
return 2
}
return 3
}
// LevenshteinDist returns the edit distance between two strings.
func LevenshteinDist(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
dp := make([]int, lb+1)
for j := 0; j <= lb; j++ {
dp[j] = j
}
for i := 1; i <= la; i++ {
prev := dp[0]
dp[0] = i
for j := 1; j <= lb; j++ {
temp := dp[j]
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
dp[j] = min(dp[j]+1, min(dp[j-1]+1, prev+cost))
prev = temp
}
}
return dp[lb]
}
-153
View File
@@ -1,153 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package cmdutil provides reusable CLI helper functions for Cobra commands.
// Both the open-source CLI and private overlays import this package to avoid
// duplicating flag validation, time parsing, and UX helpers.
package cmdutil
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
// GroupRunE is a reusable RunE for parent (group) commands that have no
// business logic of their own. With args it returns an error listing available
// subcommands; without args it shows help.
func GroupRunE(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
var names []string
for _, sub := range cmd.Commands() {
if !sub.Hidden {
names = append(names, sub.Name())
}
}
return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help",
args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath())
}
return cmd.Help()
}
// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint.
func HintSubCmd(use, hint string) *cobra.Command {
return &cobra.Command{
Use: use,
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("ambiguous command %q for %q\n hint: %s",
use, cmd.Parent().CommandPath(), hint)
},
}
}
// MustGetFlag retrieves a string flag value, checking both local and inherited flags.
func MustGetFlag(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
if val == "" {
val, _ = cmd.InheritedFlags().GetString(name)
}
return val
}
// FlagOrFallback reads the primary flag; if empty, falls back through alias
// flags in order, returning the first non-empty value.
func FlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
if v, _ := cmd.Flags().GetString(primary); v != "" {
return v
}
for _, alias := range aliases {
if v, _ := cmd.Flags().GetString(alias); v != "" {
return v
}
}
return ""
}
// MustFlagOrFallback works like FlagOrFallback but returns an error when all
// flags are empty.
func MustFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
v := FlagOrFallback(cmd, primary, aliases...)
if v == "" {
return "", fmt.Errorf("flag --%s is required\n hint: %s", primary, cmd.Example)
}
return v, nil
}
// MustFlagWithHint returns an error with an explicit usage example when the
// flag is empty.
func MustFlagWithHint(cmd *cobra.Command, name, example string) (string, error) {
v := MustGetFlag(cmd, name)
if v == "" {
return "", fmt.Errorf("flag --%s is required\n hint: %s", name, example)
}
return v, nil
}
// ValidateRequiredFlags checks that all named string flags are non-empty.
// Returns a formatted error listing all missing flags, or nil.
func ValidateRequiredFlags(cmd *cobra.Command, names ...string) error {
var missing []string
for _, name := range names {
v, _ := cmd.Flags().GetString(name)
if v == "" {
missing = append(missing, "--"+name)
}
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("missing required flag(s): %s\n usage: %s\n example:\n%s",
strings.Join(missing, ", "), cmd.UseLine(), cmd.Example)
}
// ValidateRequiredFlagWithAliases checks that at least one of the primary flag
// or its aliases is non-empty.
func ValidateRequiredFlagWithAliases(cmd *cobra.Command, primary string, aliases ...string) error {
if FlagOrFallback(cmd, primary, aliases...) != "" {
return nil
}
all := append([]string{primary}, aliases...)
return fmt.Errorf("missing required flag: --%s (or %s)\n usage: %s\n example:\n%s",
primary, "--"+strings.Join(all[1:], " / --"), cmd.UseLine(), cmd.Example)
}
// ConfirmDelete asks for interactive confirmation before destructive operations.
// Returns true if --yes/-y flag is set or the user types "yes"/"y".
func ConfirmDelete(cmd *cobra.Command, resourceType, resourceName string) bool {
if yes, _ := cmd.Flags().GetBool("yes"); yes {
return true
}
for _, arg := range os.Args[1:] {
if arg == "--yes" || arg == "-y" {
return true
}
}
fmt.Fprintf(cmd.ErrOrStderr(), "About to delete %s: %s\n", resourceType, resourceName)
fmt.Fprint(cmd.ErrOrStderr(), "Confirm deletion? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "yes" || answer == "y" {
return true
}
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
return false
}
-67
View File
@@ -1,67 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmdutil
import (
"fmt"
"strings"
"time"
)
// FlexTimeLayouts is the ordered list of time formats tried by ParseISOTimeToMillis.
var FlexTimeLayouts = []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
"2006-01-02",
"2006/01/02 15:04:05",
"2006/01/02",
"20060102",
}
// ParseISOTimeToMillis parses a time string into a millisecond Unix timestamp.
// Supports RFC3339, UTC Z, timezone-less, space-separated, date-only, and more.
// When the input lacks an explicit timezone, Asia/Shanghai is assumed.
func ParseISOTimeToMillis(flagName, value string) (int64, error) {
value = strings.TrimSpace(value)
value = strings.Trim(value, "\"'")
if value == "" {
return 0, fmt.Errorf("flag --%s is required\n hint: example: 2026-03-10T14:00:00+08:00", flagName)
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.Local
}
for _, layout := range FlexTimeLayouts {
t, err := time.ParseInLocation(layout, value, loc)
if err == nil {
return t.UnixMilli(), nil
}
}
return 0, fmt.Errorf(
"cannot parse time for --%s (input: %q)\n hint: supported formats: 2026-03-23T14:00:00+08:00, 2026-03-23 14:00:00, 2026-03-23",
flagName, value)
}
// ValidateTimeRange checks that endMs is strictly after startMs.
func ValidateTimeRange(startMs, endMs int64) error {
if endMs <= startMs {
return fmt.Errorf("--end must be after --start\n hint: swap the values or adjust the time range")
}
return nil
}
-23
View File
@@ -1,23 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package edition
// defaultHooks returns the open-source edition defaults.
// All function hooks are nil, which the internal code interprets as
// "use standard open-source behaviour".
func defaultHooks() *Hooks {
return &Hooks{
Name: "open",
}
}
-106
View File
@@ -1,106 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package edition provides an extension-point mechanism that allows private
// overlay modules (e.g. an internal distribution) to customise CLI behaviour
// without modifying the open-source core. The open-source build uses the
// zero-value defaults; an overlay calls Override before Execute.
package edition
import (
"context"
"sync"
"github.com/spf13/cobra"
)
// ServerInfo describes a static MCP server endpoint.
type ServerInfo struct {
ID string
Name string
Endpoint string
Prefixes []string
}
// ContentBlock is a single content item in a ToolResult.
type ContentBlock struct {
Type string
Text string
}
// ToolResult holds the response from an MCP tool call.
type ToolResult struct {
Content []ContentBlock
}
// ToolCaller abstracts MCP tool invocation so private overlays can call MCP
// tools without importing internal packages. The open-source core provides a
// concrete adapter wrapping executor.Runner.
type ToolCaller interface {
// CallTool invokes an MCP tool by product ID and tool name.
CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*ToolResult, error)
// Format returns the current output format ("json", "table", "raw").
Format() string
// DryRun returns true when --dry-run is active.
DryRun() bool
}
// Hooks groups all edition-specific behavioural overrides. Zero values
// fall back to open-source defaults so the struct is safe to use as-is.
type Hooks struct {
// --- identity ---
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
// --- runtime mode ---
IsEmbedded bool // true when running inside a host application
HideAuthLogin bool // true suppresses the "dws auth login" command
AutoPurgeToken bool // true deletes local token data on expiry
// --- paths ---
ConfigDir func() string // custom config directory; nil → ~/.dws
// --- HTTP headers ---
MergeHeaders func(base map[string]string) map[string]string
// --- auth hooks ---
OnAuthError func(configDir string, err error) error
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
// --- product & endpoint ---
StaticServers func() []ServerInfo // non-nil → skip Market discovery
VisibleProducts func() []string // non-nil → override help visibility
RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands
}
var (
mu sync.RWMutex
current = defaultHooks()
)
// Get returns the active edition hooks (never nil).
func Get() *Hooks {
mu.RLock()
defer mu.RUnlock()
return current
}
// Override replaces the active edition hooks. Must be called before Execute.
func Override(h *Hooks) {
if h == nil {
return
}
mu.Lock()
defer mu.Unlock()
current = h
}
-88
View File
@@ -1,88 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package editiontest provides contract-test helpers that any edition.Hooks
// implementation (open-source or overlay) must satisfy. It lives in a
// non-_test package so external modules can import and run these checks.
package editiontest
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// RunContractTests validates that the supplied Hooks implementation
// satisfies the behavioural contract required by the open-source core.
// Call this from both the open-source test suite and any overlay's tests.
func RunContractTests(t *testing.T, h *edition.Hooks) {
t.Helper()
if h == nil {
t.Fatal("Hooks must not be nil")
}
t.Run("Name", func(t *testing.T) {
if h.Name == "" {
t.Error("Hooks.Name must be non-empty")
}
})
t.Run("ConfigDir", func(t *testing.T) {
if h.ConfigDir != nil {
dir := h.ConfigDir()
if dir == "" {
t.Error("ConfigDir() must return a non-empty path when set")
}
}
})
t.Run("MergeHeaders_preserves_base", func(t *testing.T) {
if h.MergeHeaders == nil {
return
}
base := map[string]string{"x-test": "value"}
merged := h.MergeHeaders(base)
if merged == nil {
t.Fatal("MergeHeaders must return a non-nil map")
}
if merged["x-test"] != "value" {
t.Error("MergeHeaders must preserve base headers")
}
})
t.Run("StaticServers_non_empty_when_set", func(t *testing.T) {
if h.StaticServers == nil {
return
}
servers := h.StaticServers()
if len(servers) == 0 {
t.Error("StaticServers() must return at least one server when set")
}
for i, s := range servers {
if s.ID == "" || s.Endpoint == "" {
t.Errorf("StaticServers()[%d]: ID and Endpoint must be non-empty", i)
}
}
})
t.Run("VisibleProducts_non_empty_when_set", func(t *testing.T) {
if h.VisibleProducts == nil {
return
}
products := h.VisibleProducts()
if len(products) == 0 {
t.Error("VisibleProducts() must return at least one product when set")
}
})
}
-25
View File
@@ -1,25 +0,0 @@
// 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())
}
-26
View File
@@ -1,26 +0,0 @@
#!/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!"
+3 -12
View File
@@ -53,23 +53,14 @@ detect_arch() {
}
resolve_version() {
# Priority 1: Use DWS_PACKAGE_VERSION environment variable (set by CI)
if [ -n "$PACKAGE_VERSION" ]; then
# Strip leading 'v' if present for semver compatibility
printf '%s\n' "$PACKAGE_VERSION" | sed 's/^v//'
printf '%s\n' "$PACKAGE_VERSION"
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" ] || [ "$version_line" = "dev" ]; then
err "could not resolve package version - set DWS_PACKAGE_VERSION or create a git tag"
if [ -z "$version_line" ]; then
err "could not resolve package version from internal/app/version.go"
fi
printf '%s\n' "$version_line"
}
+1 -6
View File
@@ -30,11 +30,6 @@ 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
@@ -44,7 +39,7 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
}
if d.IsDir() {
switch d.Name() {
case ".git", ".worktrees", "node_modules", "dist", "plans":
case ".git", ".worktrees", "node_modules", "dist":
return filepath.SkipDir
}
return nil